From aec96353a26e9c31c1377089a649e60a7ae1e4e1 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 3 Mar 2026 12:29:39 -0800 Subject: [PATCH 1/4] Making it harder to delete all rows from lists --- api/src/org/labkey/api/query/QueryView.java | 6900 ++++++++--------- .../list/controllers/ListController.java | 2420 +++--- .../labkey/list/model/ListDefinitionImpl.java | 1686 ++-- .../labkey/list/model/ListManagerSchema.java | 500 +- .../list/model/ListQueryUpdateService.java | 1770 ++--- .../org/labkey/list/view/ListQueryView.java | 176 +- .../org/labkey/list/view/truncateListData.jsp | 70 + 7 files changed, 6840 insertions(+), 6682 deletions(-) create mode 100644 list/src/org/labkey/list/view/truncateListData.jsp diff --git a/api/src/org/labkey/api/query/QueryView.java b/api/src/org/labkey/api/query/QueryView.java index be99e20907c..8cd3b6941ff 100644 --- a/api/src/org/labkey/api/query/QueryView.java +++ b/api/src/org/labkey/api/query/QueryView.java @@ -1,3473 +1,3427 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.api.query; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.ss.usermodel.Workbook; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiQueryResponse; -import org.labkey.api.admin.notification.NotificationService; -import org.labkey.api.attachments.ByteArrayAttachmentFile; -import org.labkey.api.compliance.ComplianceService; -import org.labkey.api.data.AbstractTableInfo; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.AnalyticsProviderItem; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ButtonBarConfig; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerFilterable; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DetailsColumn; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.HtmlExportWriter; -import org.labkey.api.data.MenuButton; -import org.labkey.api.data.PanelButton; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.Results; -import org.labkey.api.data.ResultsImpl; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.TSVGridWriter; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.UpdateColumn; -import org.labkey.api.data.dialect.SqlDialect; -import org.labkey.api.exp.RawValueColumn; -import org.labkey.api.query.snapshot.QuerySnapshotService; -import org.labkey.api.reports.Report; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.report.ReportUrls; -import org.labkey.api.reports.report.r.RReport; -import org.labkey.api.reports.report.view.ReportUtil; -import org.labkey.api.reports.report.view.RunReportView; -import org.labkey.api.reports.report.view.ScriptReportBean; -import org.labkey.api.rstudio.RStudioService; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.ResourceURL; -import org.labkey.api.study.UnionTable; -import org.labkey.api.study.reports.CrosstabReport; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.StringExpressionFactory; -import org.labkey.api.util.StringUtilsLabKey; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.GridView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTrailConfig; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.PopupMenuView; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.ClientDependency; -import org.labkey.api.visualization.GenericChartReport; -import org.labkey.api.visualization.TimeChartReport; -import org.labkey.api.writer.ContainerUser; -import org.labkey.api.writer.HtmlWriter; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; - -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.net.URISyntaxException; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeMap; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.stream.Collectors; - -import static org.labkey.api.action.SpringActionController.ERROR_MSG; -import static org.labkey.api.util.DOM.P; -import static org.labkey.api.util.DOM.cl; - -/** - * View that generates the majority of standard data grids/tables in the LabKey Server UI. - * The backing query is lazily invoked when it comes time to render the QueryView. - */ -public class QueryView extends WebPartView implements ContainerUser -{ - public static final String EXPERIMENTAL_GENERIC_DETAILS_URL = "generic-details-url"; - - public static final String EXCEL_WEB_QUERY_EXPORT_TYPE = "excelWebQuery"; - public static final String DATAREGIONNAME_DEFAULT = "query"; - - private static final Logger _log = LogManager.getLogger(QueryView.class); - private static final Map _exportScriptFactories = new ConcurrentSkipListMap<>(); - - protected static final String INSERT_DATA_TEXT = "Insert Data"; - protected static final String INSERT_ROW_TEXT = "Insert New Row"; - protected static final String IMPORT_BULK_DATA_TEXT = "Import Bulk Data"; - - protected DataRegion.ButtonBarPosition _buttonBarPosition = DataRegion.ButtonBarPosition.TOP; - private ButtonBarConfig _buttonBarConfig = null; - private boolean _showDetailsColumn = true; - private boolean _showUpdateColumn = true; - private DataRegion.MessageSupplier _messageSupplier; - - private String _linkTarget; - - // Overrides for any URLs that might already be set on the TableInfo - private DetailsURL _updateURL; - private DetailsURL _detailsURL; - private String _insertURL; - private String _importURL; - private String _deleteURL; - - private boolean _hasExportRStudioPanel = false; - - - public static void register(ExportScriptFactory factory) - { - register(factory, false); - } - - public static void register(ExportScriptFactory factory, boolean overrideBaseFactory) - { - if (!overrideBaseFactory) - assert null == _exportScriptFactories.get(factory.getScriptType()); - - _exportScriptFactories.put(factory.getScriptType(), factory); - } - - public static ExportScriptFactory getExportScriptFactory(String type) - { - return _exportScriptFactories.get(type); - } - - static public QueryView create(ViewContext context, UserSchema schema, QuerySettings settings, BindException errors) - { - return schema.createView(context, settings, errors); - } - - static public QueryView create(QueryForm form, BindException errors) - { - form.ensureSchemaExists(); - - return create(form.getViewContext(), form.getSchema(), form.getQuerySettings(), errors); - } - - private QueryDefinition _queryDef; - private CustomView _customView; - private UserSchema _schema; - private Errors _errors; - private final List _parseErrors = new ArrayList<>(); - private QuerySettings _settings; - private boolean _showRecordSelectors = false; - - private boolean _shadeAlternatingRows = true; - private boolean _showFilterDescription = true; - private boolean _showBorders = true; - private boolean _showSurroundingBorder = true; - private Report _report; - - private boolean _showExportButtons = true; - private boolean _showRStudioButton = false; // might want show by default if rstudio is configured - private boolean _showInsertNewButton = true; - private boolean _showImportDataButton = true; - private boolean _showDeleteButton = true; - private boolean _showDeleteButtonConfirmationText = true; - private boolean _showConfiguredButtons = true; - private boolean _allowExportExternalQuery = true; - - private static final Set STANDARD_CONTAINER_FILTERS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders))); - - /** The container filters (called "Folder Filter" in the UI) that should be available to users in the Views menu */ - @NotNull - private Set _allowableContainerFilterTypes = STANDARD_CONTAINER_FILTERS; - private boolean _useQueryViewActionExportURLs = false; - private boolean _printView = false; - private boolean _exportView = false; - private boolean _apiResponseView = false; - private boolean _showPagination = true; - private boolean _showPaginationCount = true; - private boolean _showReports = true; - private ReportService.ItemFilter _itemFilter = DEFAULT_ITEM_FILTER; - - public static ReportService.ItemFilter DEFAULT_ITEM_FILTER = (type, label) -> - { - if (ReportService.get().getGlobalItemFilterTypes().contains(type)) return true; - if (RReport.TYPE.equals(type)) return true; - return CrosstabReport.TYPE.equals(type); - }; - - private TableInfo _table; - - public QueryView(QueryForm form, Errors errors) - { - this(form.getSchema(), form.getQuerySettings(), errors); - } - - - /** - * Must call setSettings before using the view - */ - public QueryView(UserSchema schema) - { - super(FrameType.DIV); - setSchema(schema); - } - - @Override - public void setTitle(CharSequence title) - { - super.setTitle(title); - if (StringUtils.isNotEmpty(title) && getFrame()==FrameType.DIV) - setFrame(FrameType.PORTAL); - } - - - /** Use the constructor that takes an Errors object instead */ - @Deprecated - protected QueryView(UserSchema schema, QuerySettings settings) - { - this(schema, settings, null); - } - - public QueryView(UserSchema schema, QuerySettings settings, @Nullable Errors errors) - { - this(schema); - // TODO: stop passing in null Errors. For now, new one up if null. - _errors = errors != null ? errors : new BindException(new Object(), "form"); - if (null != settings) - setSettings(settings); - } - - public QuerySettings getSettings() - { - return _settings; - } - - - protected void setSettings(QuerySettings settings) - { - if (null != _settings || null == _schema) - throw new IllegalStateException(); - _settings = settings; - _queryDef = settings.getQueryDef(_schema); - // Disable external exports (scripts, etc) since they will run in a different HTTP session that doesn't - // have access to the temporary query - if (_queryDef != null) - { - _allowExportExternalQuery &= !_queryDef.isTemporary(); - } - _customView = settings.getCustomView(getViewContext(), getQueryDef()); - } - - - protected int getMaxRows() - { - if (getShowRows() == ShowRows.NONE) - return Table.NO_ROWS; - if (getShowRows() != ShowRows.PAGINATED) - return Table.ALL_ROWS; - return getSettings().getMaxRows(); - } - - - protected long getOffset() - { - if (getShowRows() != ShowRows.PAGINATED) - return 0; - return getSettings().getOffset(); - } - - protected ShowRows getShowRows() - { - return getSettings().getShowRows(); - } - - protected String getSelectionKey() - { - return getSettings().getSelectionKey(); - } - - /** - * Returns an ActionURL for the "returnUrl" parameter or the current ActionURL if none. - */ - public URLHelper getReturnUrl() - { - return getSettings().getReturnUrlHelper(ViewServlet.getRequestURL()); - } - - protected boolean verboseErrors() - { - return true; - } - - - protected boolean ignoreUserFilter() - { - return (getViewContext().getRequest() != null && getViewContext().getRequest().getParameter(param(QueryParam.ignoreFilter)) != null) || - (getSettings() != null && getSettings().getIgnoreUserFilter()); - } - - // ignores filters on the custom view but not those added through query settings - protected boolean ignoreViewFilter() - { - return getSettings() != null && getSettings().getIgnoreViewFilter(); - } - - protected void renderErrors(HtmlWriter out, String message, List errors) - { - boolean isEditable = getQueryDef() != null && getQueryDef().canEdit(getUser()) && getQueryDef().isSqlEditable(); - P( - cl("labkey-error"), - message, - isEditable ? HtmlString.NBSP : null, - isEditable ? LinkBuilder.simpleLink("Edit Query", Objects.requireNonNull(getSchema().urlFor(QueryAction.sourceQuery, getQueryDef()))) : null - ).appendTo(out); - - Set seen = new HashSet<>(); - - if (verboseErrors()) - { - for (Throwable e : errors) - { - if (e instanceof QueryParseException) - { - out.write(e.getMessage()); - } - else - { - out.write(e.toString()); - } - - String resolveURL = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveURL); - if (null != resolveURL && seen.add(resolveURL)) - { - String resolveText = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveText); - if (getUser().isPlatformDeveloper()) - { - out.write(" "); - out.write(LinkBuilder.labkeyLink(Objects.toString(resolveText, "resolve"), resolveURL)); - } - } - out.write(HtmlString.BR); - } - } - } - - /* delay load menu, because it is usually visible==false */ - private class QueryNavTreeMenuButton extends MenuButton - { - private boolean populated = false; - - QueryNavTreeMenuButton(String label) - { - super(label); - setVisible(false); - } - - @Override - public void setVisible(boolean visible) - { - if (visible && !populated) - { - populateMenu(); - populated = true; - } - super.setVisible(visible); - } - - private void populateMenu() - { - if (getQueryDef() != null) - { - NavTree editQueryItem; - if (getQueryDef().isSqlEditable() && getQueryDef().canEdit(getUser())) - editQueryItem = new NavTree("Edit Source", getSchema().urlFor(QueryAction.sourceQuery, getQueryDef())); - else - editQueryItem = new NavTree("View Definition", getSchema().urlFor(QueryAction.schemaBrowser, getQueryDef())); - addMenuItem(editQueryItem); - - if (getQueryDef().isMetadataEditable() && getQueryDef().canEditMetadata(getUser())) - { - NavTree editMetadataItem = new NavTree("Edit Metadata", getSchema().urlFor(QueryAction.metadataQuery, getQueryDef())); - addMenuItem(editMetadataItem); - } - } - - addSeparator(); - - if (getSchema().shouldRenderTableList()) - { - String current = getQueryDef() != null ? getQueryDef().getName() : null; - URLHelper target = urlRefreshQuery(); - - for (QueryDefinition query : getSchema().getTablesAndQueries(true)) - { - String name = query.getName(); - NavTree item = new NavTree(name, target.clone().replaceParameter(param(QueryParam.queryName), name)); - // Intentionally don't set the description so we can avoid having to instantiate all of the TableInfos, - // which can be expensive for some schemas - if (name.equals(current)) - item.setStrong(true); - item.setImageSrc(new ResourceURL("/reports/grid.gif")); - item.setImageCls("fa fa-table"); - addMenuItem(item); - } - } - else - { - ActionURL schemaBrowserURL = PageFlowUtil.urlProvider(QueryUrls.class).urlSchemaBrowser(getContainer(), getSchema().getName()); - addMenuItem("Schema Browser", schemaBrowserURL); - } - } - } - - - public MenuButton createQueryPickerButton(String label) - { - return new QueryNavTreeMenuButton(label); - } - - - @Override - public User getUser() - { - return _schema.getUser(); - } - - public UserSchema getSchema() - { - return _schema; - } - - protected void setSchema(UserSchema schema) - { - if (null != _settings || null != _schema) - throw new IllegalStateException(); - _schema = schema; - } - - @Override - public Container getContainer() - { - return _schema.getContainer(); - } - - protected StringExpression urlExpr(QueryAction action) - { - StringExpression expr = switch (action) - { - case detailsQueryRow -> _detailsURL; - case updateQueryRow -> _updateURL; - default -> null; - - // NOTE: details/update URL may not get picked up from TableInfo if subclass overrides createTable() - // but that case should use QueryView.setDetailsURL/setUpdateURL() anyway - }; - - if (null == expr) - expr = getQueryDef().urlExpr(action, _schema.getContainer()); - - if (expr == null) - return null; - - // Don't append the returnUrl parameter in API responses - if (!isApiResponseView()) - { - switch (action) - { - case detailsQueryRow: - case updateQueryRow: - case insertQueryRow: - case importData: - case updateQueryRows: - case deleteQueryRows: - { - // ICK - URLHelper returnUrl = getReturnUrl(); - if (returnUrl != null) - { - String encodedReturnURL = PageFlowUtil.encode(returnUrl.getLocalURIString()); - expr = ((StringExpressionFactory.AbstractStringExpression) expr).addParameter(ActionURL.Param.returnUrl.name(), encodedReturnURL); - } - } - } - } - - return expr; - } - - @Nullable - protected ActionURL urlFor(QueryAction action) - { - ActionURL ret = null; - switch (action) - { - case deleteQueryRows: - if (null != _deleteURL) - ret = DetailsURL.fromString(_deleteURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - case detailsQueryRow: - // TODO kinda suspect... since this is a per-row url - if (null != _detailsURL) - ret = _detailsURL.getActionURL(); - break; - case updateQueryRow: - // TODO also kinda suspect... - if (null != _updateURL) - ret = _updateURL.getActionURL(); - break; - case insertQueryRow: - if (null != _insertURL) - ret = DetailsURL.fromString(_insertURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - case importData: - if (null != _importURL) - ret = DetailsURL.fromString(_importURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - } - - if (null == ret && null != getQueryDef()) - ret = _schema.urlFor(action, getQueryDef()); - - if (ret == null) - { - return null; - } - - // Issue 11280: Export URLs don't include the query's base sort/filter. - // The solution is to expand the custom view's saved sort/filter before adding the base sort/filter. - // NOTE: This is a temporary solution. - // - // We won't need to expand the saved custom view filters or analyticsProviders. Filters can be applied - // in any order and the analyticsProviders don't make much sense in the exported xls or tsv files. - // - // The correct long term solution is to (a) create proper QueryView subclasses using UserSchema.createView() - // and (b) use POST instead of GET for the export actions (or others) to match the LABKEY.QueryWebPart config behavior. - // Using POST is necessary since the LABKEY.QueryWebPart config expresses other options (column lists, grid rendering options, etc) that can't be expressed on URLs. - // - // Issue 17313: Exporting from a grid should respect "Apply View Filter" state - if (_customView != null) - { - if (_customView.getName() != null) - ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.viewName, _customView.getName()); - - if (!ignoreUserFilter() && _customView != null && _customView.hasFilterOrSort()) - { - _customView.applyFilterAndSortToURL(ret, DATAREGIONNAME_DEFAULT); - } - } - - // Applying the base sort/filter to the url is lossy in that anyone consuming the url can't - // determine if the sort/filter originated from QuerySettings or from a user applied sort/filter. - getSettings().getBaseFilter().applyToURL(ret, DATAREGIONNAME_DEFAULT); - - if (!getSettings().getBaseSort().getSortList().isEmpty()) - getSettings().getBaseSort().applyToURL(ret, DATAREGIONNAME_DEFAULT, true); - - switch (action) - { - case deleteQuery: - case sourceQuery: - break; - case detailsQueryRow: - case updateQueryRow: - case insertQueryRow: - case importData: - case updateQueryRows: - case deleteQueryRows: - ret.addReturnUrl(getReturnUrl()); - break; - case editSnapshot: - ret.addParameter("snapshotName", getSettings().getQueryName()); - case createSnapshot: - - case exportRowsExcel: - case exportRowsXLSX: - case exportRowsTsv: - case exportScript: - case signRowsExcel: - case signRowsXLSX: - case signRowsTsv: - case selectAll: - case printRows: - { - if (_useQueryViewActionExportURLs) - { - ret = getViewContext().cloneActionURL(); - ret.addParameter("exportType", action.name()); - ret.addParameter("dataRegionName", getExportRegionName()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(getExportRegionName() + ".maxRows"); - ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); - break; - } - ActionURL expandedURL = getViewContext().cloneActionURL(); - addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); - // Copy the other parameters that aren't scoped to the data region as well. Some exports may use them. - // For example, see issue 15451 - for (Map.Entry entry : expandedURL.getParameterMap().entrySet()) - { - String name = entry.getKey(); - // schemaName isn't prefixed with the data region name, and don't specify a special data region name - if (!name.equals("schemaName") && !name.equals("dataRegionName") && !name.startsWith(getDataRegionName() + ".") && !name.startsWith(DATAREGIONNAME_DEFAULT + ".")) - { - for (String value : entry.getValue()) - { - ret.addParameter(entry.getKey(), value); - } - } - } - - ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.selectionKey, getSelectionKey()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); - ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); - break; - } - case excelWebQueryDefinition: - { - if (_useQueryViewActionExportURLs) - { - ActionURL expandedURL = getViewContext().cloneActionURL(); - expandedURL.addParameter("exportType", EXCEL_WEB_QUERY_EXPORT_TYPE); - expandedURL.addParameter("exportRegion", getDataRegionName()); - ret.addParameter("queryViewActionURL", expandedURL.getLocalURIString()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(getExportRegionName() + ".maxRows"); - ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); - break; - } - ActionURL expandedURL = getViewContext().cloneActionURL(); - addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); - ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); - break; - } - case createRReport: - ScriptReportBean bean = new ScriptReportBean(); - bean.setReportType(RReport.TYPE); - bean.setSchemaName(_schema.getSchemaName()); - bean.setQueryName(getSettings().getQueryName()); - bean.setViewName(getSettings().getViewName()); - bean.setDataRegionName(getDataRegionName()); - - bean.setRedirectUrl(getReturnUrl().getLocalURIString()); - return ReportUtil.getScriptReportDesignerURL(_viewContext, bean); - } - return ret; - } - - protected ActionButton actionButton(String label, QueryAction action) - { - return actionButton(label, action, null, null); - } - - protected ActionButton actionButton(String label, QueryAction action, @Nullable String parameterToAdd, @Nullable String parameterValue) - { - ActionURL url = urlFor(action); - if (url == null) - { - return null; - } - if (parameterToAdd != null) - url.addParameter(parameterToAdd, parameterValue); - return new ActionButton(label, url); - } - - protected String param(QueryParam param) - { - return param(param.toString()); - } - - protected String param(String param) - { - return getDataRegionName() + "." + param; - } - - protected URLHelper urlRefreshQuery() - { - URLHelper ret = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - ret = ret.clone(); - ret.deleteParameter(param(QueryParam.queryName)); - ret.deleteParameter(param(QueryParam.viewName)); - ret.deleteParameter(param(QueryParam.reportId)); - for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) - { - ret.deleteFilterParameters(key); - } - return ret; - } - - protected ActionURL urlBaseView() - { - ActionURL ret = getSettings().getSortFilterURL(); - for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) - { - ret.deleteFilterParameters(key); - } - ret.deleteParameter(DataRegion.LAST_FILTER_PARAM); - return ret; - } - - protected URLHelper urlChangeView() - { - URLHelper ret = getSettings().getReturnUrlHelper(); - if (null == ret) - { - ret = getSettings().getSortFilterURL(); - } - else if (getSettings().getDataRegionName() != null) - { - ret = ret.clone(); - // if we are using a returnUrl for this QV, make sure we apply any sort and filter - // parameters so that reports stay in sync with the data region. - URLHelper url = getSettings().getSortFilterURL(); - for (String param : url.getKeysByPrefix(getSettings().getDataRegionName())) - { - ret.replaceParameter(param, url.getParameter(param)); - } - } - else - { - ret = ret.clone(); - } - - ret.deleteParameter(param(QueryParam.viewName)); - ret.deleteParameter(param(QueryParam.reportId)); - ret.deleteParameter(RunReportView.CACHE_PARAM); - ret.deleteParameter(RunReportView.TAB_PARAM); - return ret; - } - - protected void addParamsByPrefix(ActionURL target, ActionURL source, String oldPrefix, String newPrefix) - { - for (String key : source.getKeysByPrefix(oldPrefix)) - { - String suffix = key.substring(oldPrefix.length()); - String newKey = newPrefix + suffix; - for (String value : source.getParameterValues(key)) - { - boolean isQueryParam = false; - try - { - Enum.valueOf(QueryParam.class, suffix); - isQueryParam = true; - } - catch (Exception ignore) { } - - if (suffix.equals("sort")) - { - // Prepend source sort parameter before target's existing sort - String targetSort = target.getParameter(key); - if (targetSort != null && !targetSort.isEmpty()) - value = value + "," + targetSort; - target.replaceParameter(newKey, value); - } - else if (isQueryParam) - { - // Issue 20779: Error: Query 'Containers,Containers' in schema 'core' doesn't exist - // Issue 21101: Cannot export QueryWebPart views using a custom sql query to Excel file - // Only a single non-empty value is accepted for query parameters -- overwrite the existing parameter so we don't have duplicate parameters. - if (value != null && !value.isEmpty()) - target.replaceParameter(newKey, value); - } - else - { - target.addParameter(newKey, value); - } - } - } - } - - protected boolean canInsert() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), InsertPermission.class) && table.getUpdateService() != null; - } - - protected boolean canUpdate() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), UpdatePermission.class) && table.getUpdateService() != null; - } - - protected boolean canDelete() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), DeletePermission.class); - } - - protected boolean isAdmin() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), AdminPermission.class); - } - - private boolean allowQueryTableInsertURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasInsertURLOverride() && table.allowQueryTableURLOverrides(); - } - - protected boolean allowQueryTableUpdateURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasUpdateURLOverride() && table.allowQueryTableURLOverrides(); - } - - private boolean allowQueryTableDeleteURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasDeleteURLOverride() && table.allowQueryTableURLOverrides(); - } - - public boolean showInsertNewButton() - { - return _showInsertNewButton; - } - - public void setShowInsertNewButton(boolean showInsertNewButton) - { - _showInsertNewButton = showInsertNewButton; - } - - public boolean showImportDataButton() - { - return _showImportDataButton; - } - - public void setShowImportDataButton(boolean show) - { - _showImportDataButton = show; - } - - public boolean showDeleteButton() - { - return _showDeleteButton; - } - - public void setShowDeleteButton(boolean showDeleteButton) - { - _showDeleteButton = showDeleteButton; - } - - public boolean showDeleteButtonConfirmationText() - { - return _showDeleteButtonConfirmationText; - } - - public void setShowDeleteButtonConfirmationText(boolean showDeleteButtonConfirmationText) - { - _showDeleteButtonConfirmationText = showDeleteButtonConfirmationText; - } - - public boolean showRecordSelectors() - { - return _showRecordSelectors; - } - - /** - * Show record selectors usually doesn't need to be explicitly set. If the ButtonBar contains - * a button that requires selection, the record selectors will be added. - */ - public void setShowRecordSelectors(boolean showRecordSelectors) - { - _showRecordSelectors = showRecordSelectors; - } - - protected void populateReportButtonBar(ButtonBar bar) - { - MenuButton queryButton = createQueryPickerButton("Query"); - queryButton.setVisible(getSettings().getAllowChooseQuery()); - bar.add(queryButton); - - if (getSettings().getAllowChooseView()) - { - bar.add(createViewButton(_itemFilter)); - populateChartsReports(bar); - } - - if (showExportButtons()) - { - ActionButton b = createPrintButton(); - if (null != b) - bar.add(b); - } - } - - protected void populateButtonBar(DataView view, ButtonBar bar) - { - MenuButton queryButton = createQueryPickerButton("Query"); - queryButton.setVisible(getSettings().getAllowChooseQuery()); - bar.add(queryButton); - - if (getSettings().getAllowChooseView()) - { - bar.add(createViewButton(_itemFilter)); - } - - populateChartsReports(bar); - - if ((canInsert() || allowQueryTableInsertURLOverride()) && (showInsertNewButton() || showImportDataButton())) - { - bar.add(createInsertMenuButton()); - } - - if ((canDelete() || allowQueryTableDeleteURLOverride()) && showDeleteButton()) - { - bar.add(createDeleteButton()); - } - - if (showExportButtons()) - { - List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); - - PanelButton b = createExportButton(recordSelectorColumns); - if (b.hasSubPanels()) - { - // Issue 24530: Add record selectors for exporting selected items. Assumes that all export panels support selection. - if ((recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || (getTable() != null && !getTable().getPkColumns().isEmpty())) - { - bar.setAlwaysShowRecordSelectors(true); - } - bar.add(b); - } - - ActionButton rs = createExportToRStudioButton(); - if (null != rs) - bar.add(rs); - } - } - - @Nullable ActionButton createExportToRStudioButton() - { - ActionButton rstudio = new ActionButton("RStudio"); - String script = DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleButtonPanel('export','rstudio'); return false;"; - rstudio.setScript(script, false); - rstudio.setVisible(showRStudioButton()); - rstudio.setEnabled(_hasExportRStudioPanel); - rstudio.setDisplayPermission(ReadPermission.class); - return rstudio; - } - - @Nullable - public ActionButton createEditMultipleButton() - { - ActionButton btn = null; - ActionURL editMultipleURL = urlFor(QueryAction.updateQueryRows); - if (editMultipleURL != null) - { - editMultipleURL.addParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY, _settings.getSelectionKey()); - btn = new ActionButton(editMultipleURL, "Edit Multiple"); - btn.setActionType(ActionButton.Action.POST); - btn.setDisplayPermission(UpdatePermission.class); - btn.setRequiresSelection(true, 2, null); - } - return btn; - } - - @Nullable - public ActionButton createDeleteButton() - { - return createDeleteButton(showDeleteButtonConfirmationText()); - } - - public ActionButton createDeleteButton(boolean showConfirmation) - { - ActionURL urlDelete = urlFor(QueryAction.deleteQueryRows); - if (urlDelete != null) - { - ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); - btnDelete.setIconCls("trash"); - btnDelete.setActionType(ActionButton.Action.POST); - btnDelete.setDisplayPermission(DeletePermission.class); - if (showConfirmation) - btnDelete.setRequiresSelection(true, "Are you sure you want to delete the selected row?", "Are you sure you want to delete the selected rows?"); - else - btnDelete.setRequiresSelection(true); - return btnDelete; - } - return null; - } - - public ActionButton createDeleteAllRowsButton(String tableNoun) - { - ActionButton deleteAllRows = new ActionButton("Delete All Rows"); - deleteAllRows.setDisplayPermission(AdminPermission.class); - deleteAllRows.setActionType(ActionButton.Action.SCRIPT); - deleteAllRows.setScript( - "LABKEY.requiresExt4Sandbox(function() {" + - "Ext4.Msg.confirm('Confirm Deletion', 'Are you sure you wish to delete all rows in this " + tableNoun + "? This action cannot be undone and will result in an empty " + tableNoun + ".', function(button){" + - "if (button == 'yes'){" + - "var waitMask = Ext4.Msg.wait('Deleting Rows...', 'Delete Rows'); " + - "Ext4.Ajax.request({ " + - "url : LABKEY.ActionURL.buildURL('query', 'truncateTable'), " + - "method : 'POST', " + - "success: function(response) " + - "{" + - "waitMask.close(); " + - "var data = Ext4.JSON.decode(response.responseText); " + - "Ext4.Msg.show({ " + - "title : 'Success', " + - "buttons : Ext4.MessageBox.OK, " + - "msg : data.deletedRows + ' rows deleted', " + - "fn: function(btn) " + - "{ " + - "if(btn == 'ok') " + - "{ " + - "window.location.reload(); " + - "} " + - "} " + - "})" + - "}, " + - "failure : function(response, opts) " + - "{ " + - "waitMask.close(); " + - "Ext4.getBody().unmask(); " + - "LABKEY.Utils.displayAjaxErrorResponse(response, opts); " + - "}, " + - "jsonData : {schemaName : " + PageFlowUtil.jsString(getQueryDef().getSchema().getName()) + ", queryName : " + PageFlowUtil.jsString(getQueryDef().getName()) + "}, " + - "scope : this " + - "});" + - "}" + - "});" + - "});" - ); - return deleteAllRows; - } - - public ActionButton createInsertMenuButton() - { - return createInsertMenuButton(null, null); - } - - public ActionButton createInsertMenuButton(ActionURL overrideInsertUrl, ActionURL overrideImportUrl) - { - MenuButton button = new MenuButton("Insert"); - button.setTooltip(getInsertButtonText(INSERT_DATA_TEXT)); - button.setIconCls("plus"); - boolean hasInsertNewOption = false; - boolean hasImportDataOption = false; - - if (showInsertNewButton()) - { - ActionURL urlInsert = overrideInsertUrl == null ? urlFor(QueryAction.insertQueryRow) : overrideInsertUrl; - if (urlInsert != null) - { - NavTree insertNew = new NavTree(getInsertButtonText(getInsertButtonText(INSERT_ROW_TEXT)), urlInsert); - button.addMenuItem(insertNew); - hasInsertNewOption = true; - } - } - - if (showImportDataButton()) - { - ActionURL urlImport = overrideImportUrl == null ? urlFor(QueryAction.importData) : overrideImportUrl; - if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) - { - NavTree importData = new NavTree(getInsertButtonText(IMPORT_BULK_DATA_TEXT), urlImport); - button.addMenuItem(importData); - hasImportDataOption = true; - } - } - - return hasInsertNewOption && hasImportDataOption? button : hasInsertNewOption ? createInsertButton() : hasImportDataOption ? createImportButton() : null; - } - - public ActionButton createInsertButton() - { - ActionURL urlInsert = urlFor(QueryAction.insertQueryRow); - if (urlInsert != null) - { - ActionButton btnInsert = new ActionButton(urlInsert, getInsertButtonText(INSERT_ROW_TEXT)); - btnInsert.setActionType(ActionButton.Action.LINK); - btnInsert.setTooltip(getInsertButtonText(INSERT_ROW_TEXT)); - btnInsert.setIconCls("plus"); - return btnInsert; - } - return null; - } - - public ActionButton createImportButton() - { - ActionURL urlImport = urlFor(QueryAction.importData); - if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) - { - ActionButton btnInsert = new ActionButton(urlImport, getInsertButtonText(IMPORT_BULK_DATA_TEXT)); - btnInsert.setActionType(ActionButton.Action.LINK); - btnInsert.setTooltip(getInsertButtonText(IMPORT_BULK_DATA_TEXT)); - btnInsert.setIconCls("plus"); - return btnInsert; - } - return null; - } - - protected String getInsertButtonText(String btnTxt) - { - return StringUtils.capitalize(btnTxt.toLowerCase()); - } - - @Nullable - protected ActionButton createPrintButton() - { - ActionButton btnPrint = actionButton("Print", QueryAction.printRows); - if (null == btnPrint) - return null; - btnPrint.setIconCls("print"); - btnPrint.setTarget("_blank"); - return btnPrint; - } - - private ActionButton createShareButton(@NotNull ActionURL url, @Nullable String tooltip) - { - ActionButton shareBtn = new ActionButton(url, "Share"); - shareBtn.setActionType(ActionButton.Action.LINK); - shareBtn.setIconCls("share"); - if (tooltip != null) - shareBtn.setTooltip(tooltip); - - return shareBtn; - } - - /** - * Make all links rendered in columns target the specified browser window/tab - */ - public void setLinkTarget(String linkTarget) - { - _linkTarget = linkTarget; - } - - public abstract static class ExportOptionsBean - { - private final String _dataRegionName; - private final String _exportRegionName; - private final String _selectionKey; - private final ColumnHeaderType _headerType; - private final boolean _includeSignButton; - private final String _email; - - protected ExportOptionsBean(String dataRegionName, String exportRegionName, @Nullable String selectionKey, - ColumnHeaderType headerType, boolean includeSignButton, @Nullable String email) - { - _dataRegionName = dataRegionName; - _exportRegionName = exportRegionName; - _selectionKey = selectionKey; - _headerType = headerType; - _includeSignButton = includeSignButton; - _email = email; - } - - public String getDataRegionName() - { - return _dataRegionName; - } - - public String getExportRegionName() - { - return _exportRegionName; - } - - @Nullable - public String getSelectionKey() - { - return _selectionKey; - } - - /** @return false if the region won't support row selectors, usually because it doesn't have a primary key */ - public boolean isSelectable() - { - return _selectionKey != null; - } - - public boolean hasSelected(ViewContext context) - { - if (!isSelectable()) - { - return false; - } - Set selected = DataRegionSelection.getSelected(context, _selectionKey, false); - return !selected.isEmpty(); - } - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public boolean isIncludeSignButton() - { - return _includeSignButton; - } - - public String getEmail() - { - return _email; - } - } - - public static class ExcelExportOptionsBean extends ExportOptionsBean - { - private final ActionURL _xlsURL; - private final ActionURL _xlsxURL; - private final ActionURL _iqyURL; - private final ActionURL _signXlsURL; - private final ActionURL _signXlsxURL; - - public ExcelExportOptionsBean( - String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, - ActionURL xlsURL, ActionURL xlsxURL, ActionURL iqyURL, ActionURL signXlsURL, ActionURL signXlsxURL, @Nullable String email) - { - super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signXlsURL && null != signXlsxURL), email); - _xlsURL = xlsURL; - _xlsxURL = xlsxURL; - _iqyURL = iqyURL; - _signXlsURL = null != signXlsURL ? signXlsURL : new ActionURL(); - _signXlsxURL = null != signXlsxURL ? signXlsxURL : new ActionURL(); - } - - @NotNull - public ActionURL getXlsxURL() - { - return _xlsxURL; - } - - public ActionURL getIqyURL() - { - return _iqyURL; - } - - @NotNull - public ActionURL getXlsURL() - { - return _xlsURL; - } - - @NotNull - public ActionURL getSignXlsURL() - { - return _signXlsURL; - } - - @NotNull - public ActionURL getSignXlsxURL() - { - return _signXlsxURL; - } - } - - public static class TextExportOptionsBean extends ExportOptionsBean - { - private final ActionURL _tsvURL; - private final ActionURL _signTsvURL; - - public TextExportOptionsBean( - String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, - ActionURL tsvURL, ActionURL signTsvURL, @Nullable String email) - { - super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signTsvURL), email); - _tsvURL = tsvURL; - _signTsvURL = null != signTsvURL ? signTsvURL : new ActionURL(); - } - - @NotNull - public ActionURL getTsvURL() - { - return _tsvURL; - } - - @NotNull - public ActionURL getSignTsvURL() - { - return _signTsvURL; - } - } - - @NotNull - public PanelButton createExportButton(@Nullable List recordSelectorColumns) - { - String buttonText = "Export"; - ActionURL signRowsXlsURL = null; - ActionURL signRowsXlsxURL = null; - ActionURL signRowsTsvURL = null; - ComplianceService complianceService = ComplianceService.get(); - if (complianceService.hasElecSignPermission(getContainer(), getUser()) && !getUser().isImpersonated()) - { - // We build a URL using Query's mechanism because it does a lot of work to get the properties right; - // Then build our URL to the ComplianceController using those properties. If any fail, just bail on creating button. - signRowsXlsURL = complianceService.urlFor(getContainer(), QueryAction.signRowsExcel, urlFor(QueryAction.signRowsExcel)); - signRowsXlsxURL = complianceService.urlFor(getContainer(), QueryAction.signRowsXLSX, urlFor(QueryAction.signRowsXLSX)); - signRowsTsvURL = complianceService.urlFor(getContainer(), QueryAction.signRowsTsv, urlFor(QueryAction.signRowsTsv)); - if (null != signRowsXlsURL && null != signRowsXlsxURL && null != signRowsTsvURL) - buttonText += " / Sign Data"; - } - - PanelButton button = new PanelButton("export", buttonText, getDataRegionName()); - button.setActionName("export"); // #32594: API can set a buttonConfig including "export"; since the caption may differ, add action so BuiltinButtonConfig can figure it out - ActionURL xlsURL = urlFor(QueryAction.exportRowsExcel); - ActionURL xlsxURL = urlFor(QueryAction.exportRowsXLSX); - ActionURL tsvURL = urlFor(QueryAction.exportRowsTsv); - - button.setIconCls("download"); - button.setTabAlignTop(true); - boolean hasRecordSelectors = (recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || - (getTable() != null && !getTable().getPkColumns().isEmpty()); - - if (xlsURL != null && xlsxURL != null) - { - ExcelExportOptionsBean excelBean = new ExcelExportOptionsBean( - getDataRegionName(), - getExportRegionName(), - hasRecordSelectors ? getSettings().getSelectionKey() : null, - getColumnHeaderType(), - xlsURL, - xlsxURL, - _allowExportExternalQuery ? urlFor(QueryAction.excelWebQueryDefinition) : null, - signRowsXlsURL, - signRowsXlsxURL, - getUser().getEmail() - ); - button.addSubPanel("Excel", new JspView<>("/org/labkey/api/query/excelExportOptions.jsp", excelBean)); - } - - if (tsvURL != null) - { - TextExportOptionsBean textBean = new TextExportOptionsBean( - getDataRegionName(), - getExportRegionName(), - hasRecordSelectors ? getSettings().getSelectionKey() : null, - getColumnHeaderType(), - tsvURL, - signRowsTsvURL, - getUser().getEmail() - ); - button.addSubPanel("Text", new JspView<>("/org/labkey/api/query/textExportOptions.jsp", textBean)); - } - - if (_allowExportExternalQuery) - { - addExportScriptItems(button); - addExportRStudio(button, hasRecordSelectors ? getSettings().getSelectionKey() : null); - } - - return button; - } - - - public void addExportRStudio(PanelButton exportButton, String selectionKey) - { - RStudioService rss = RStudioService.get(); - if (null == rss || null == rss.getRStudioLink(getUser(), getContainer())) - return; - if (null == getExportScriptFactory("r")) - return; - ActionURL exportUrl = urlFor(QueryAction.exportScript); - if (null == exportUrl) - return; - exportUrl.replaceParameter("scriptType","r"); - TextExportOptionsBean textBean = new TextExportOptionsBean(getDataRegionName(), getExportRegionName(), selectionKey, - getColumnHeaderType(), exportUrl, null, null); - HttpView exportView = rss.getExportToRStudioView(textBean); - if (exportView == null) - return; - exportButton.addSubPanel("RStudio", exportView); - _hasExportRStudioPanel = true; - } - - - public void addExportScriptItems(PanelButton button) - { - if (!_exportScriptFactories.isEmpty()) - { - Map options = new LinkedHashMap<>(); - - for (ExportScriptFactory factory : _exportScriptFactories.values()) - { - ActionURL url = urlFor(QueryAction.exportScript); - if (null != url) - { - url.addParameter("scriptType", factory.getScriptType()); - options.put(factory.getMenuText(), url); - } - } - - if (!options.isEmpty()) - button.addSubPanel("Script", new JspView<>("/org/labkey/api/query/scriptExportOptions.jsp", options)); - } - } - - public ReportService.ItemFilter getViewItemFilter() - { - return _itemFilter; - } - - public void setViewItemFilter(ReportService.ItemFilter filter) - { - if (filter != null) - _itemFilter = filter; - } - - public MenuButton createViewButton(ReportService.ItemFilter filter) - { - setViewItemFilter(filter); - String current = null; - - // if we are not rendering a report or not showing reports, we use the current view name to set the menu item - // selection, an empty string denotes the default view, a customized default view will have a null name. - if (_report == null || !_showReports) - current = (_customView != null) ? Objects.toString(_customView.getName(), "") : ""; - - URLHelper target = urlChangeView(); - MenuButton button = new MenuButton("Grid Views"); - button.setTooltip("Grid views"); - button.setIconCls("table"); - NavTree menu = button.getNavTree(); - - if (getSettings().isAllowCustomizeView()) - addCustomizeViewItems(button); - - if (!getQueryDef().isTemporary()) - { - button.addSeparator(); - addGridViews(button, target, current); - button.addSeparator(); - addManageViewItems(button, PageFlowUtil.map( - "schemaName", getSchema().getSchemaName(), - "queryName", getSettings().getQueryName())); - addFilterItems(button); - } - - return button; - } - - protected MenuButton createReportButton() - { - MenuButton button = new MenuButton("Reports"); - NavTree menu = button.getNavTree(); - - if (!getQueryDef().isTemporary() && _report == null) - { - List reportDesigners = new ArrayList<>(); - getSettings().setSchemaName(getSchema().getSchemaName()); - - for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) - { - for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) - { - if (designerInfo.getType() != ReportService.DesignerType.VISUALIZATION) - reportDesigners.add(designerInfo); - } - } - - reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); - - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (ReportService.DesignerInfo designer : reportDesigners) - { - if (viewItemFilter.accept(designer.getReportType(), designer.getLabel())) - { - NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); - item.setImageSrc(designer.getIconURL()); - item.setImageCls(designer.getIconCls()); - - menu.addChild(item); - } - } - } - - // existing reports - if (!getQueryDef().isTemporary()) - { - addReportViews(button); - } - - return button; - } - - private MenuButton createChartButton() - { - MenuButton button = new MenuButton("Charts"); - button.setIconCls("area-chart"); - - if (!getQueryDef().isTemporary() && _report == null) - { - List reportDesigners = new ArrayList<>(); - getSettings().setSchemaName(getSchema().getSchemaName()); - - for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) - { - for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) - { - if (designerInfo.getType() == ReportService.DesignerType.VISUALIZATION) - reportDesigners.add(designerInfo); - } - } - - reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); - - for (ReportService.DesignerInfo designer : reportDesigners) - { - NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); - item.setImageSrc(designer.getIconURL()); - item.setImageCls(designer.getIconCls()); - button.addMenuItem(item); - } - } - - if (!getQueryDef().isTemporary()) - { - addChartViews(button); - } - - return button; - } - - protected void populateChartsReports(ButtonBar bar) - { - if (isShowReports()) - { - MenuButton reportButton = createReportButton(); - MenuButton chartButton = createChartButton(); - NavTree uiProviderLinks = createUIProviderLinks(); - - if (reportButton.getNavTree().hasChildren()) - { - chartButton.setTooltip("Charts / Reports"); - NavTree chartMenu = chartButton.getNavTree(); - chartMenu.addSeparator(); - for (NavTree child : reportButton.getNavTree().getChildren()) - chartButton.addMenuItem(child); - } - if (uiProviderLinks != null && uiProviderLinks.hasChildren()) - { - chartButton.addSeparator(); - for (NavTree child : uiProviderLinks.getChildren()) - chartButton.addMenuItem(child); - } - - if (chartButton.getNavTree().hasChildren()) - bar.add(chartButton); - } - } - - private NavTree createUIProviderLinks() - { - NavTree menu = null; - List uiProviders = ReportService.get().getUIProviders(); - Map> uiProviderAddedViews = new TreeMap<>(); - - for (ReportService.UIProvider provider : uiProviders) - { - for (Pair additionalItem : provider.getAdditionalChartingMenuItems(getViewContext(), getSettings())) - { - if (!uiProviderAddedViews.containsKey(additionalItem.second)) - uiProviderAddedViews.put(additionalItem.second, new ArrayList<>()); - uiProviderAddedViews.get(additionalItem.second).add(additionalItem.first); - } - } - - if (!uiProviderAddedViews.isEmpty()) - { - menu = new NavTree(); - for (Map.Entry> entry : uiProviderAddedViews.entrySet()) - { - List navItems = entry.getValue(); - navItems.sort(Comparator.comparing(NavTree::getText)); - for (NavTree item : navItems) - menu.addChild(item); - } - } - - return menu; - } - - public ReportService.ItemFilter getItemFilter() - { - QueryDefinition def = QueryService.get().getQueryDef(getUser(), getContainer(), getSchema().getSchemaName(), getSettings().getQueryName()); - if (def == null) - def = QueryService.get().createQueryDefForTable(getSchema(), getSettings().getQueryName()); - - return new WrappedItemFilter(_itemFilter, def); - } - - private static class WrappedItemFilter implements ReportService.ItemFilter - { - private final ReportService.ItemFilter _filter; - private final Map _filterItemMap = new HashMap<>(); - - - public WrappedItemFilter(ReportService.ItemFilter filter, QueryDefinition def) - { - _filter = filter; - - if (def != null) - { - for (ViewOptions.ViewFilterItem item : def.getViewOptions().getViewFilterItems()) - _filterItemMap.put(item.getViewType(), item); - } - } - - @Override - public boolean accept(String type, String label) - { - if (_filter.accept(type, label)) - { - if (_filterItemMap.containsKey(type)) - return _filterItemMap.get(type).isEnabled(); - else - return true; - } - - if (_filterItemMap.containsKey(type)) - return _filterItemMap.get(type).isEnabled(); - - return false; - } - } - - protected void addFilterItems(MenuButton button) - { - if (_customView != null && _customView.hasFilterOrSort()) - { - URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - url = url.clone(); - NavTree item; - String label = "Apply Grid Filter"; - if (ignoreUserFilter()) - { - url.deleteParameter(param(QueryParam.ignoreFilter)); - item = new NavTree(label, url); - } - else - { - url.replaceParameter(param(QueryParam.ignoreFilter), "1"); - item = new NavTree(label, url); - item.setSelected(true); - } - item.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".clearSelected({quiet: true});"); - button.addMenuItem(item); - } - - TableInfo t = getTable(); - if (t instanceof UnionTable ut) - { - t = ut.getComponentTable(); // check against a component table - } - if (null != t && t.supportsContainerFilter() && !getAllowableContainerFilterTypes().isEmpty()) - { - NavTree containerFilterItem = new NavTree("Folder Filter"); - button.addMenuItem(containerFilterItem); - - ContainerFilter selectedFilter = getContainerFilter(); - ContainerFilter.Type selectedFilterType = null != selectedFilter ? selectedFilter.getType() : ContainerFilter.Type.Current; - - for (ContainerFilter.Type filterType : getAllowableContainerFilterTypes()) - { - URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - url = url.clone(); - String propName = getDataRegionName() + DataRegion.CONTAINER_FILTER_NAME; - url.replaceParameter(propName, filterType.name()); - NavTree filterItem = new NavTree(filterType.toString(), url); - - if (selectedFilterType == filterType) - { - filterItem.setSelected(true); - } - filterItem.setNoFollow(true); - containerFilterItem.addChild(filterItem); - } - } - } - - protected String getChangeViewScript(String viewName) - { - return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'view', viewName:" + PageFlowUtil.jsString(viewName) + "});"; - } - - protected String getChangeReportScript(String reportId) - { - return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'report', reportId:" + PageFlowUtil.jsString(reportId) + "});"; - } - - protected void addGridViews(MenuButton menu, URLHelper target, String currentView) - { - List views = new ArrayList<>(getQueryDef().getCustomViews(getViewContext().getUser(), getViewContext().getRequest(), false, false).values()); - List viewItems = new ArrayList<>(); - - // default grid view stays at the top level. The default will have a getName == null - boolean hasDefault = false; - for (CustomView view : views) - { - if (view.getName() == null) - { - hasDefault = true; - break; - } - } - - // To make generating menu items easier, create a default custom view if it doesn't exist yet. - if (!hasDefault) - { - // don't pass getUser() as owner, we want the default view to appear as "public" - CustomView defaultView = getQueryDef().createCustomView(); - views.add(0, defaultView); - } - - // sort the grid view alphabetically, with default first (null name), then private views over public ones - views.sort((o1, o2) -> - { - if (o1.getName() == null) return -1; - if (o2.getName() == null) return 1; - if (!o1.isShared() && o2.isShared()) return -1; - if (o1.isShared() && !o2.isShared()) return 1; - - return o1.getName().compareToIgnoreCase(o2.getName()); - }); - - for (CustomView view : views) - { - if (view.isHidden()) - continue; - - NavTree item; - String name = view.getName(); - if (name == null) - { - String label = Objects.toString(view.getLabel(), "Default"); - - item = new NavTree(label, (ActionURL) null); - item.setScript(getChangeViewScript("")); - if ("".equals(currentView)) - item.setStrong(true); - } - else - { - String label = view.getLabel(); - - item = new NavTree(label, (ActionURL) null); - item.setScript(getChangeViewScript(name)); - if (name.equals(currentView)) - item.setStrong(true); - } - - StringBuilder description = new StringBuilder(); - if (view.isSession()) - { - item.setEmphasis(true); - description.append("Unsaved "); - } - if (view.isShared()) - description.append("Shared "); - else - description.append("Private "); - - if (view.getContainer() != null && !view.getContainer().equals(getContainer())) - description.append("Inherited from '").append(PageFlowUtil.filter(view.getContainer().getPath())).append("'"); - - if (!description.isEmpty()) - item.setDescription(description.toString()); - - try - { - URLHelper iconUrl; - if (null != view.getCustomIconUrl()) - iconUrl = new URLHelper(view.getCustomIconUrl()); - else - iconUrl = new URLHelper(view.isShared() ? "/reports/grid.gif" : "/reports/icon_private_view.png"); - iconUrl.setContextPath(AppProps.getInstance().getParsedContextPath()); - item.setImageSrc(iconUrl); - - if (null != view.getCustomIconCls()) - item.setImageCls(view.getCustomIconCls()); - } - catch (URISyntaxException e) - { - _log.error("Invalid custom view icon url", e); - } - - viewItems.add(item); - menu.addMenuItem(item); - } - - // enable menu filtering for the module list if > 10 items - if (viewItems.size() > 10) - { - String menuFilterItemCls = PopupMenuView.getMenuFilterItemCls(menu.getNavTree()); - for (NavTree item : viewItems) - item.setMenuFilterItemCls(menuFilterItemCls); - } - - } - - protected void addReportViews(MenuButton menu) - { - List allReports = new ArrayList<>(); - // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too - for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) - { - allReports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); - } - Map> views = new TreeMap<>(); - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (Report report : allReports) - { - // Filter out reports that don't match what this view is supposed to show. This can prevent - // reports that were created on the same schema and table/query from a different view from showing up on a - // view that's doing magic to add additional filters, for example. - if (viewItemFilter.accept(report.getType(), null) - && !report.getType().equals(TimeChartReport.TYPE) - && !report.getType().equals(GenericChartReport.TYPE)) - { - if (canViewReport(getUser(), getContainer(), report) && !report.getDescriptor().isHidden()) - { - if (!views.containsKey(report.getType())) - views.put(report.getType(), new ArrayList<>()); - - views.get(report.getType()).add(report); - } - } - } - - if (!views.isEmpty()) - menu.addSeparator(); - - for (Map.Entry> entry : views.entrySet()) - { - List reports = entry.getValue(); - - // sort the list of reports within each type grouping - reports.sort((o1, o2) -> - { - String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); - String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); - - return n1.compareToIgnoreCase(n2); - }); - - for (Report report : reports) - { - String reportId = report.getDescriptor().getReportId().toString(); - NavTree item = new NavTree(report.getDescriptor().getReportName(), (ActionURL) null); - if (report.getDescriptor().getReportId().equals(getSettings().getReportId())) - item.setStrong(true); - item.setImageSrc(ReportUtil.getIconUrl(getContainer(), report)); - item.setScript(getChangeReportScript(reportId)); - menu.addMenuItem(item); - } - } - } - - protected void addChartViews(MenuButton menu) - { - List reports = new ArrayList<>(); - // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too - for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) - { - reports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); - } - Map> views = new TreeMap<>(); - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (Report report : reports) - { - // Filter out reports that don't match what this view is supposed to show. This can prevent - // reports that were created on the same schema and table/query from a different view from showing up on a - // view that's doing magic to add additional filters, for example. - if (viewItemFilter.accept(report.getType(), null) && - (report.getType().equals(TimeChartReport.TYPE) || report.getType().equals(GenericChartReport.TYPE))) - { - if (canViewReport(getUser(), getContainer(), report)) - { - if (!views.containsKey(report.getType())) - views.put(report.getType(), new ArrayList<>()); - - views.get(report.getType()).add(report); - } - } - } - - if (!views.isEmpty()) - menu.addSeparator(); - - for (Map.Entry> entry : views.entrySet()) - { - List charts = entry.getValue(); - - charts.sort((o1, o2) -> - { - String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); - String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); - - return n1.compareToIgnoreCase(n2); - }); - - for (Report chart : charts) - { - String chartId = chart.getDescriptor().getReportId().toString(); - NavTree item = new NavTree(chart.getDescriptor().getReportName(), (ActionURL) null); - item.setImageSrc(ReportUtil.getIconUrl(getContainer(), chart)); - item.setImageCls(ReportUtil.getIconCls(chart)); - item.setScript(getChangeReportScript(chartId)); - - if (chart.getDescriptor().getReportId().equals(getSettings().getReportId())) - item.setStrong(true); - - menu.addMenuItem(item); - } - } - } - - protected boolean canViewReport(User user, Container c, Report report) - { - return true; - } - - public void addCustomizeViewItems(MenuButton button) - { - if (_report == null) - { - ActionURL urlTableInfo = getSchema().urlFor(QueryAction.tableInfo); - urlTableInfo.addParameter(QueryParam.queryName.toString(), getQueryDef().getName()); - - NavTree customizeView = new NavTree("Customize Grid"); - customizeView.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleShowCustomizeView();"); - customizeView.setImageCls("fa fa-pencil"); - button.addMenuItem(customizeView); - } - - if (isAdmin() && QueryService.get().isQuerySnapshot(getContainer(), getSchema().getSchemaName(), getSettings().getQueryName())) - { - QuerySnapshotService.Provider provider = QuerySnapshotService.get(getSchema().getSchemaName()); - if (provider != null) - { - NavTree item = button.addMenuItem("Edit Snapshot", provider.getEditSnapshotURL(getSettings(), getViewContext())); - } - } - } - - public void addManageViewItems(MenuButton button, Map params) - { - ActionURL url = PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(getContainer()); - for (Map.Entry entry : params.entrySet()) - url.addParameter(entry.getKey(), entry.getValue()); - - NavTree item = button.addMenuItem("Manage Views", url); - item.setImageCls("fa fa-cog"); - } - - public String getDataRegionName() - { - return getSettings().getDataRegionName(); - } - - private String getExportRegionName() - { - return _useQueryViewActionExportURLs ? getDataRegionName() : DATAREGIONNAME_DEFAULT; - } - - private String _baseId = null; - - /** - * Use this html encoded dataRegionName as the base id for menus and attribute values that need to be rendered into the DOM. - */ - protected String getBaseMenuId() - { - if (_baseId == null) - _baseId = PageFlowUtil.filter(getDataRegionName()); - return _baseId; - } - - protected String h(Object o) - { - return PageFlowUtil.filter(o); - } - - /** - * this is the choke point for rendering reports and views, if this method is overridden you need to call - * super in order to have report/view rendering to work properly. - */ - @Override - protected void renderView(Object model, HttpServletRequest request, HttpServletResponse response) throws Exception - { - if (isReportView(getViewContext())) - renderReportView(request, response); - else - renderDataRegion(HtmlWriter.of(response)); - } - - private void renderReportView(HttpServletRequest request, HttpServletResponse response) throws IOException - { - if (_report != null) - { - try - { - ReportDataRegion dr = new ReportDataRegion(getSettings(), getViewContext(), _report); - RenderContext ctx = new RenderContext(getViewContext()); - - if (!isPrintView()) - { - // not sure why this is necessary (adding the reportId to the context) - ctx.put("reportId", _report.getDescriptor().getReportId()); - - ButtonBar bar = new ButtonBar(); - populateReportButtonBar(bar); - - if (_report.allowShareButton(getUser(), getContainer())) - { - ActionURL shareUrl = PageFlowUtil.urlProvider(ReportUrls.class).urlShareReport(getContainer(), _report); - if (shareUrl != null) - bar.add(createShareButton(shareUrl, "Share report")); - } - - dr.setButtonBar(bar); - } - dr.render(ctx, request, response); - - // if the user is viewing a shared report, remove any notifications related to it - NotificationService.get().removeNotifications( - getContainer(), _report.getDescriptor().getReportId().toString(), - Collections.singletonList(Report.SHARE_REPORT_TYPE), getUser().getUserId() - ); - } - catch (Exception e) - { - renderErrors(HtmlWriter.of(response), "Error rendering report : " + _report.getDescriptor().getReportName(), Collections.singletonList(e)); - } - } - } - - protected SqlDialect getSqlDialect() - { - return getSchema().getDbSchema().getSqlDialect(); - } - - protected DataRegion createDataRegion() - { - DataRegion rgn = new DataRegion(); - configureDataRegion(rgn); - return rgn; - } - - protected void configureDataRegion(DataRegion rgn) - { - rgn.setDisplayColumns(getDisplayColumns()); - rgn.setSettings(getSettings()); - rgn.setShowRecordSelectors(showRecordSelectors()); - rgn.setSelectAllURL(urlFor(QueryAction.selectAll)); - - rgn.setShadeAlternatingRows(isShadeAlternatingRows()); - rgn.setShowFilterDescription(isShowFilterDescription()); - rgn.setShowBorders(isShowBorders()); - rgn.setShowSurroundingBorder(isShowSurroundingBorder()); - rgn.setShowPagination(isShowPagination()); - rgn.setShowPaginationCount(isShowPaginationCount()); - - if (_messageSupplier != null) - rgn.addMessageSupplier(_messageSupplier); - - if (_customView != null && _customView.getErrors() != null) - { - rgn.addMessageSupplier(dataRegion -> _customView.getErrors().stream() - .map(e -> new DataRegion.Message(e, DataRegion.MessageType.ERROR, DataRegion.MessagePart.view)) - .collect(Collectors.toList())); - } - - TableInfo table = getTable(); - if (table instanceof FilteredTable ft && ft.hasRulesOmittedColumns()) - { - rgn.addMessageSupplier(x -> List.of(new DataRegion.Message("PHI protected columns have been omitted", DataRegion.MessageType.WARNING, DataRegion.MessagePart.header))); - } - - // Allow region to specify header lock, optionally override - if (rgn.getAllowHeaderLock()) - rgn.setAllowHeaderLock(getSettings().getAllowHeaderLock()); - - rgn.setTable(table); - - if (isShowConfiguredButtons()) - { - // We first apply the button bar config from the table: - ButtonBarConfig tableBarConfig = table == null ? null : table.getButtonBarConfig(); - if (tableBarConfig != null) - rgn.addButtonBarConfig(tableBarConfig); - // Then any overriding button bar config (from javascript) is applied: - if (_buttonBarConfig != null) - rgn.addButtonBarConfig(_buttonBarConfig); - } - - if (table != null && table.getAggregateRowConfig() != null) - { - rgn.setAggregateRowConfig(table.getAggregateRowConfig()); - } - } - - public void setButtonBarPosition(DataRegion.ButtonBarPosition buttonBarPosition) - { - _buttonBarPosition = buttonBarPosition; - } - - public void setButtonBarConfig(ButtonBarConfig buttonBarConfig) - { - _buttonBarConfig = buttonBarConfig; - } - - public ButtonBarConfig getButtonBarConfig() - { - return _buttonBarConfig; - } - - private boolean isReportView(ViewContext viewContext) - { - _report = getSettings().getReportView(viewContext); - - return _report != null && StringUtils.trimToNull(getSettings().getViewName()) == null; - } - - public DataView createDataView() - { - DataRegion rgn = createDataRegion(); - - //if explicit set of fieldkeys has been set - //add those specifically to the region - if (null != getSettings().getFieldKeys()) - { - TableInfo table = getTable(); - if (table != null) - { - rgn.clearColumns(); - List keys = getSettings().getFieldKeys(); - FieldKey starKey = FieldKey.fromParts("*"); - - // include details and update columns if they've been requested - addDetailsAndUpdateColumns(rgn.getDisplayColumns(), table); - - //special-case: if one of the keys is *, add all columns from the - //TableInfo and remove the * so that Query doesn't choke on it - if (keys.contains(starKey)) - { - rgn.addColumns(table.getColumns()); - keys.remove(starKey); - // Since the client requested all columns, don't filter which ones get sent back - getSettings().setFieldKeys(null); - } - - if (!keys.isEmpty()) - { - Map selectedCols = QueryService.get().getColumns(table, keys); - for (ColumnInfo col : selectedCols.values()) - rgn.addColumn(col); - } - } - } - else if (null != getSettings().getExtraFieldKeys()) - { - TableInfo table = getTable(); - if (table != null) - { - List keys = getSettings().getExtraFieldKeys(); - if (!keys.isEmpty()) - { - Map selectedCols = QueryService.get().getColumns(table, keys); - for (ColumnInfo col : selectedCols.values()) - rgn.addColumn(col); - } - } - } - - GridView ret = new GridView(rgn, _errors); - setupDataView(ret); - return ret; - } - - protected void setupDataView(DataView ret) - { - DataRegion rgn = ret.getDataRegion(); - ret.setFrame(WebPartView.FrameType.NONE); - rgn.setAllowAsync(true); - ButtonBar bb = new ButtonBar(); - if (!(isApiResponseView() || isPrintView() || isExportView())) - { - populateButtonBar(ret, bb); - - // TODO: Until the "More" menu is dynamically populated the "Print" button has been moved back to the bar. - // Print button is rendered separately to respect ordering -- we want it rendering after all custom buttons - // added by overrides of populateButtonBar(). - // bar.add(populateMoreMenu()); - if (showExportButtons()) - bb.add(createPrintButton()); - } - rgn.setButtonBar(bb); - - rgn.setButtonBarPosition(isApiResponseView() || isPrintView() ? DataRegion.ButtonBarPosition.NONE : _buttonBarPosition); - - if (getSettings() != null && getSettings().getShowRows() == ShowRows.ALL) - { - // Don't cache if the ResultSet is likely to be very large - ret.getRenderContext().setCache(false); - } - - ActionURL customViewUrl = null; - if (_customView != null && _customView.hasFilterOrSort() && !ignoreViewFilter()) - { - customViewUrl = new ActionURL(); - _customView.applyFilterAndSortToURL(customViewUrl, getDataRegionName()); - } - - // Apply base sorts and filters from custom view and from QuerySettings. - if (!ignoreUserFilter()) - { - SimpleFilter filter; - if (ret.getRenderContext().getBaseFilter() instanceof SimpleFilter) - { - filter = (SimpleFilter) ret.getRenderContext().getBaseFilter(); - } - else - { - filter = new SimpleFilter(ret.getRenderContext().getBaseFilter()); - } - Sort sort = ret.getRenderContext().getBaseSort(); - if (sort == null) - { - sort = new Sort(); - } - - // We need to set the base sort/filter _before_ adding the customView sort/filter. - // If the user has set a sort on their custom view, we want their sort to take precedence. - filter.addAllClauses(getSettings().getBaseFilter()); - sort.insertSort(getSettings().getBaseSort()); - - if (customViewUrl != null) - { - try - { - filter.addUrlFilters(customViewUrl, getDataRegionName()); - } - catch (ConversionException e) - { - _errors.reject(ERROR_MSG, "Invalid grid view filter: " + e.getMessage()); - } - sort.addURLSort(customViewUrl, getDataRegionName()); - } - - ret.getRenderContext().setBaseFilter(filter); - ret.getRenderContext().setBaseSort(sort); - } - - // Apply analytics providers from custom view and query settings - List analyticsProviders = new LinkedList<>(); - if (ret.getRenderContext().getBaseAnalyticsProviders() != null) - analyticsProviders.addAll(ret.getRenderContext().getBaseAnalyticsProviders()); - if (getSettings().getAnalyticsProviders() != null) - analyticsProviders.addAll(getSettings().getAnalyticsProviders()); - if (customViewUrl != null) - analyticsProviders.addAll(AnalyticsProviderItem.fromURL(customViewUrl, getDataRegionName())); - ret.getRenderContext().setBaseAnalyticsProviders(analyticsProviders); - - // XXX: Move to QuerySettings? - if (_customView != null) - ret.getRenderContext().setView(_customView); - - // TODO: Don't set available container filters in render context - // 11082: Need to push list of available container filters to DataRegion.js - ret.getRenderContext().put("allowableContainerFilterTypes", getAllowableContainerFilterTypes()); - } - - - protected void renderDataRegion(HtmlWriter out) throws Exception - { - // make sure table has been instantiated - getTable(); - List errors = getParseErrors(); - if (errors.isEmpty()) - { - include(createDataView(), out.unwrap()); - } - else - { - renderErrors(out, "Query '" + getQueryDef().getName() + "' has errors", errors); - } - } - - - protected ColumnHeaderType getColumnHeaderType() - { - return ColumnHeaderType.Caption; - } - - public TSVGridWriter getTsvWriter() throws IOException - { - return getTsvWriter(getColumnHeaderType()); - } - - protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType) throws IOException - { - return getTsvWriter(headerType, Collections.emptyMap()); - } - - protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType, @NotNull Map renameColumnMap) - { - _exportView = true; - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setAllowAsync(false); - rgn.setShowPagination(false); - rgn.prepareDisplayColumns(getContainer()); - RenderContext rc = view.getRenderContext(); - rc.setCache(false); - TSVGridWriter tsv = new TSVGridWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), renameColumnMap); - tsv.setFilenamePrefix(getSettings().getQueryName() != null ? getSettings().getQueryName() : "query"); - // don't step on default - if (null != headerType) - tsv.setColumnHeaderType(headerType); - return tsv; - } - - public Results getResults() throws SQLException, IOException - { - return getResults(ShowRows.ALL); - } - - public Results getResults(ShowRows showRows) throws SQLException, IOException - { - return getResults(showRows, false, false); - } - - public Results getResults(ShowRows showRows, boolean async, boolean cache) throws SQLException, IOException - { - _exportView = true; - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - ShowRows prevShowRows = getSettings().getShowRows(); - try - { - // Set to the desired row policy - getSettings().setShowRows(showRows); - rgn.setAllowAsync(async); - view.getRenderContext().setCache(cache); - RenderContext ctx = view.getRenderContext(); - if (null == rgn.getResults(ctx)) - return null; - return new ResultsImpl(ctx); - } - finally - { - // We have to reset the show-rows setting, since we don't know what's going to be done with this - // queryview after the call to 'getResults'. It's possible it could still be rendered to the client, - // as happens with study datasets. - getSettings().setShowRows(prevShowRows); - } - } - - - @Nullable - public ResultSet getResultSet() throws SQLException, IOException - { - Results r = getResults(); - return r == null ? null : r.getResultSet(); - } - - - public List getExportColumns(List list) - { - List ret = new ArrayList<>(list); - ret.removeIf(next -> next instanceof DetailsColumn || next instanceof UpdateColumn); - return ret; - } - - public final ExcelWriter getExcelWriter(@NotNull ExcelExportConfig config) throws IOException - { - // Call the appropriate overridden method - ExcelWriter ew = getExcelWriter(config.getDocType(), null); - return configureExcelWriter(ew, config); - } - - public ExcelWriter getExcelWriter(ExcelWriter.ExcelDocumentType docType, @Nullable Map renameColumnMap) throws IOException - { - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - RenderContext rc = configureForExcelExport(docType, view, rgn); - - ExcelWriter ew = new ExcelWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), docType, renameColumnMap); - - ew.setFilenamePrefix(getSettings().getQueryName()); - ew.setAutoSize(true); - return ew; - } - - /** - * Sets configuration settings for the provided ExcelWriter according the provided config and this QueryView - * @param excelWriter to configure (CALLER TO CLOSE) - * @param config additional properties to set on the writer - */ - public ExcelWriter configureExcelWriter(ExcelWriter excelWriter, ExcelExportConfig config) - { - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - RenderContext rc = configureForExcelExport(excelWriter.getDocumentType(), view, rgn); - rgn.prepareDisplayColumns(view.getViewContext().getContainer()); - rgn.setAllowAsync(false); - excelWriter.setDisplayColumns(getExportColumns(rgn.getDisplayColumns())); - excelWriter.setResultsFactory(()->rgn.getResults(rc)); - excelWriter.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); - excelWriter.setRenameColumnMap(config.getRenamedColumns()); - excelWriter.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); - excelWriter.setAutoSize(true); - - return excelWriter; - } - - protected ExcelWriter getExcelTemplateWriter(@NotNull ExcelExportConfig config) - { - // The template should be based on the actual columns in the table, not the user's default view, - // which may be hiding columns or showing values joined through lookups - - //NOTE: if the the user passed a viewName param on the URL, we will use these columns - //with the caveat that we will skip and non-user editable columns or those that do - //map to fields in this table (ie. lookups). we will also append any missing - //required columns. - - //TODO: the latter might be problematic if the value of required column is set - //in a validation script. however, the dev could always set it to userEditable=false or nullable=true - List fieldKeys = new ArrayList<>(20); - TableInfo t = createTable(); - - if (!config.getRespectView()) - { - for (ColumnInfo columnInfo : t.getColumns()) - { - FieldKey fieldKey = columnInfo.getFieldKey(); - // Issue 43760: "isUserEditable" does not mean what you think it means. UniqueIdFields must be marked as "UserEditable" - // in order to show up in a details view, but then that makes them show up in the export, where they shouldn't. Booo. - if (config.getIncludeColumns().contains(fieldKey) || (columnInfo.isUserEditable() && !columnInfo.isUniqueIdField())) - { - fieldKeys.add(fieldKey); - } - } - - // Add remaining includeCols to the end - for (FieldKey includeCol : config.getIncludeColumns()) - { - if (!fieldKeys.contains(includeCol)) - fieldKeys.add(includeCol); - } - - } - else - { - // get list of required columns so we can verify presence - Set requiredCols = new HashSet<>(config.getIncludeColumns()); - for (ColumnInfo c : t.getColumns()) - { - if (c.inferIsShownInInsertView()) - requiredCols.add(c.getFieldKey()); - } - - - for (FieldKey key : getCustomView().getColumns()) - { - if (key.getParent() != null) - continue; - - if (requiredCols.contains(key)) - { - fieldKeys.add(key); - requiredCols.remove(key); - continue; - } - - Map cols = QueryService.get().getColumns(t, Collections.singleton(key)); - ColumnInfo col = cols.get(key); - if (col != null && col.isUserEditable()) - { - fieldKeys.add(key); - requiredCols.remove(key); - } - } - - // Add any remaining required columns to the end - fieldKeys.addAll(requiredCols); - } - - List displayColumns = getExcelTemplateDisplayColumns(fieldKeys); - return new ExcelWriter(()->null, displayColumns, config.getDocType(), config.getRenamedColumns()); - } - - protected List getExcelTemplateDisplayColumns(List fieldKeys) - { - // Force the view to use our special list - getSettings().setFieldKeys(fieldKeys); - - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setAllowAsync(false); - rgn.setShowPagination(false); - - // Add explicitly requested columns, even if they don't actually exist on the table. - // They may be magic columns supported on the import side, e.g. "MaterialsInputs/Foo" for SampleTypes. - List displayColumns = rgn.getDisplayColumns(); - Set displayColumnFieldKeys = displayColumns.stream() - .map(DisplayColumn::getColumnInfo) - .filter(Objects::nonNull) - .map(ColumnInfo::getFieldKey) - .collect(Collectors.toSet()); - - for (FieldKey fieldKey : fieldKeys) - { - if (!displayColumnFieldKeys.contains(fieldKey)) - { - DisplayColumn dc = new SimpleDisplayColumn(); - dc.setName(fieldKey.getName()); - displayColumns.add(dc); - } - } - - displayColumns = getExportColumns(displayColumns); - - // Need to remove special MV columns - displayColumns.removeIf(col -> col.getColumnInfo() instanceof RawValueColumn); - - return displayColumns; - } - - protected RenderContext configureForExcelExport(ExcelWriter.ExcelDocumentType docType, DataView view, DataRegion rgn) - { - if (getSettings().getShowRows() == ShowRows.ALL) - { - // Limit the rows returned based on the document type. - // The maxRows setting isn't used unless showRows is PAGINATED. - getSettings().setShowRows(ShowRows.PAGINATED); - getSettings().setMaxRows(docType.getMaxRows()); - } - getSettings().setOffset(Table.NO_OFFSET); - rgn.prepareDisplayColumns(view.getViewContext().getContainer()); // Prep the display columns to translate generic date/time formats, see #21094 - rgn.setAllowAsync(false); - RenderContext rc = view.getRenderContext(); - // Cache resultset only for SAS/SHARE data sources. See #12966 (which removed caching) and #13638 (which added it back for SAS) - boolean sas = "SAS".equals(rgn.getTable().getSqlDialect().getProductName()); - rc.setCache(sas); - return rc; - } - - public static class ExcelExportConfig - { - private HttpServletResponse response; - private ColumnHeaderType headerType; - private Workbook workbook = null; - private ExcelWriter.ExcelDocumentType docType = ExcelWriter.ExcelDocumentType.xlsx; - private Map renamedColumns = new HashMap<>(); - private boolean templateOnly = false; - private boolean insertColumnsOnly = false; - private boolean respectView = false; - private List includeColumns = Collections.emptyList(); - private List excludeColumns = Collections.emptyList(); - private String prefix = null; - - public ExcelExportConfig(HttpServletResponse response, ColumnHeaderType headerType) - { - this.response = response; - this.headerType = headerType; - } - - public ExcelExportConfig setPrefix(String prefix) - { - this.prefix = prefix; - return this; - } - - public String getPrefix() - { - return this.prefix; - } - - public ExcelExportConfig setExcludeColumns(List excludeColumns) - { - this.excludeColumns = excludeColumns; - return this; - } - - public List getExcludeColumns() - { - return this.excludeColumns; - } - - public ExcelExportConfig setIncludeColumns(List includeColumns) - { - this.includeColumns = includeColumns; - return this; - } - - public List getIncludeColumns() - { - return this.includeColumns; - } - - public ExcelExportConfig setRespectView(boolean respectView) - { - this.respectView = respectView; - return this; - } - - public boolean getRespectView() - { - return this.respectView; - } - - public ExcelExportConfig setInsertColumnsOnly(boolean insertColumnsOnly) - { - this.insertColumnsOnly = insertColumnsOnly; - return this; - } - - public boolean getInsertColumnsOnly() - { - return this.insertColumnsOnly; - } - - public ExcelExportConfig setHeaderType(ColumnHeaderType headerType) - { - this.headerType = headerType; - return this; - } - - public ColumnHeaderType getHeaderType() - { - return this.headerType; - } - - public ExcelExportConfig setTemplateOnly(boolean templateOnly) - { - this.templateOnly = templateOnly; - return this; - } - - public boolean getTemplateOnly() - { - return this.templateOnly; - } - - public ExcelExportConfig setRenamedColumns(Map renamedColumns) - { - this.renamedColumns = renamedColumns; - return this; - } - - public Map getRenamedColumns() - { - return this.renamedColumns; - } - - public ExcelExportConfig setDocType(ExcelWriter.ExcelDocumentType docType) - { - this.docType = docType; - return this; - } - - public ExcelWriter.ExcelDocumentType getDocType() - { - return this.docType; - } - - public ExcelExportConfig setResponse(HttpServletResponse response) - { - this.response = response; - return this; - } - - public @NotNull HttpServletResponse getResponse() - { - return this.response; - } - public ExcelExportConfig setWorkbook(Workbook workbook) - { - this.workbook = workbook; - return this; - } - - public @Nullable Workbook getWorkbook() - { - return this.workbook; - } - } - - public void exportToExcel(HttpServletResponse response) throws IOException - { - exportToExcel(new ExcelExportConfig(response, getColumnHeaderType())); - } - - public void exportToExcel(HttpServletResponse response, Workbook workbook) throws IOException - { - exportToExcel(new ExcelExportConfig(response, getColumnHeaderType()).setWorkbook(workbook)); - } - - public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType) throws IOException - { - exportToExcel(new ExcelExportConfig(response, headerType).setDocType(docType)); - } - - public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType, @NotNull Map renameColumn) throws IOException - { - exportToExcel( - new ExcelExportConfig(response, headerType) - .setDocType(docType) - .setRenamedColumns(renameColumn) - ); - } - - public void exportToExcel(ExcelExportConfig config) throws IOException - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - ExcelWriter ew = config.getTemplateOnly() ? getExcelTemplateWriter(config) : getExcelWriter(config); - ew.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); - ew.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); - if (config.getPrefix() != null) - ew.setFilenamePrefix(config.getPrefix()); - ew.setAutoSize(true); - ew.renderWorkbook(config.getResponse()); - - if (!config.getTemplateOnly()) - logAuditEvent("Exported to Excel", ew.getDataRowCount()); - } - } - - @Nullable - public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, @Nullable Map metadata, - @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - return exportToExcelFile(docType, getColumnHeaderType(), metadata, rowsOut, includeTimestamp); - } - - @Nullable - public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, ColumnHeaderType headerType, @Nullable Map metadata, - @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); - try (OutputStream stream = new BufferedOutputStream(byteStream)) - { - ExcelWriter ew = getExcelWriter(docType, null); - ew.setCaptionType(headerType); - ew.setShowInsertableColumnsOnly(false, null); - ew.setMetadata(metadata); - ew.renderWorkbook(stream); - String extension = docType.name(); - String filename = includeTimestamp ? - FileUtil.makeFileNameWithTimestamp(ew.getFilenamePrefix(), extension) : - ew.getFilenamePrefix() + "." + extension; - ByteArrayAttachmentFile byteArrayAttachmentFile = - new ByteArrayAttachmentFile(filename, byteStream.toByteArray(), docType.getMimeType()); - - if (null != rowsOut) - rowsOut.add(ew.getDataRowCount()); - logAuditEvent("Exported to Excel file", ew.getDataRowCount()); - return byteArrayAttachmentFile; - } - } - - return null; - } - - public void exportToTsv(HttpServletResponse response) throws IOException - { - exportToTsv(response, TSVWriter.DELIM.TAB, TSVWriter.QUOTE.DOUBLE, getColumnHeaderType()); - } - - public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType) throws IOException - { - exportToTsv(response, delim, quote, headerType, Collections.emptyMap()); - } - - public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException - { - _exportView = true; - TableInfo table = getTable(); - - if (table != null) - { - int rowCount = doExport(response, delim, quote, headerType, renameColumnMap); - logAuditEvent("Exported to TSV", rowCount); - } - } - - private int doExport(HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException - { - try (TSVGridWriter tsv = renameColumnMap.isEmpty() ? getTsvWriter(headerType) : getTsvWriter(headerType, renameColumnMap)) - { - tsv.setDelimiterCharacter(delim); - tsv.setQuoteCharacter(quote); - tsv.write(response); - return tsv.getDataRowCount(); - } - } - - @Nullable - public ByteArrayAttachmentFile exportToTsvFile(final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, - @Nullable List commentLines, @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - StringBuilder tsvBuilder = new StringBuilder(); - - try (TSVGridWriter tsvWriter = getTsvWriter(headerType)) - { - tsvWriter.setDelimiterCharacter(delim); - tsvWriter.setQuoteCharacter(quote); - if (null != commentLines) - tsvWriter.setFileHeader(commentLines); - tsvWriter.write(tsvBuilder); - String extension = delim.extension; - String filename = includeTimestamp ? - FileUtil.makeFileNameWithTimestamp(tsvWriter.getFilenamePrefix(), extension) : - tsvWriter.getFilenamePrefix() + "." + extension; - String contentType = delim.contentType; - ByteArrayAttachmentFile byteArrayAttachmentFile = new ByteArrayAttachmentFile(filename, tsvBuilder.toString().getBytes(StringUtilsLabKey.DEFAULT_CHARSET), contentType); - - if (null != rowsOut) - rowsOut.add(tsvWriter.getDataRowCount()); - logAuditEvent("Exported to TSV file", tsvWriter.getDataRowCount()); - return byteArrayAttachmentFile; - } - } - - return null; - } - - public void exportToApiResponse(ApiQueryResponse response) - { - TableInfo table = getTable(); - if (table != null) - { - _apiResponseView = true; - setShowDetailsColumn(response.isIncludeDetailsColumn()); - setShowUpdateColumn(response.isIncludeUpdateColumn()); - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setShowPaginationCount(!response.isMetaDataOnly()); - - //force the pk column(s) into the default list of columns - List pkCols = table.getPkColumns(); - for (ColumnInfo pkCol : pkCols) - { - if (null == rgn.getDisplayColumn(pkCol.getName())) - rgn.addColumn(pkCol); - } - - RenderContext ctx = view.getRenderContext(); - rgn.setAllowAsync(false); - rgn.prepareDisplayColumns(ctx.getContainer()); - List displayColumns; - if (response.isIncludeDetailsColumn() || response.isIncludeUpdateColumn()) - displayColumns = rgn.getDisplayColumns(); - else - displayColumns = getExportColumns(rgn.getDisplayColumns()); - response.initialize(ctx, rgn, table, displayColumns); - } - else - { - //table was null--try to get parse errors - List errors = getParseErrors(); - if (null != errors && !errors.isEmpty()) - throw errors.get(0); - } - } - - public void exportToExcelWebQuery(HttpServletResponse response) throws Exception - { - TableInfo table = getTable(); - if (null == table) - return; - - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - // Backwards compatibility for export URLs that don't specify a showRows value, see issue 24523 - if (getViewContext().getRequest().getParameter(getSettings().getDataRegionName() + ".showRows") == null) - { - getSettings().setShowRows(ShowRows.ALL); - } - - // We're not sure if we're dealing with a version of Excel that can handle more than 65535 rows. - // Assume that it can, and rely on the fact that Excel throws out rows if there are more than it can handle - RenderContext ctx = configureForExcelExport(ExcelWriter.ExcelDocumentType.xlsx, view, rgn); - - Results results = rgn.getResults(ctx); - - // Bug 5610 & 6179. Excel web queries don't work over SSL if caching is disabled, - // so we need to allow caching so that Excel can read from IE on Windows. - // Set the headers to allow the client to cache, but not proxies - ResponseHelper.setPrivate(response); - - HtmlExportWriter writer = new HtmlExportWriter(); - writer.write(results, getExportColumns(rgn.getDisplayColumns()), response, ctx, true); - - logAuditEvent("Exported to Excel Web Query data", writer.getDataRowCount()); - } - - /** - * Mark all rows in the query view as selected in the user's session. - */ - public int selectAll() throws IOException - { - if (StringUtils.isEmpty(getSelectionKey())) - throw new IllegalStateException(); - - TableInfo table = getTable(); - if (table == null) - throw new IllegalStateException(); - - return DataRegionSelection.setSelectionForAll(this, this.getSelectionKey(), true); - } - - public void logAuditEvent(String comment, int dataRowCount) - { - QueryService.get().addAuditEvent(this, comment, dataRowCount); - } - - public CustomView getCustomView() - { - return _customView; - } - - public void setCustomView(CustomView customView) - { - _customView = customView; - } - - public void setCustomView(String viewName) - { - _settings.setViewName(viewName); - _customView = _settings.getCustomView(getViewContext(), getQueryDef()); - } - - protected TableInfo createTable() - { - QueryDefinition qdef = getQueryDef(); - if (null == qdef) - return null; - qdef.setContainerFilter(getContainerFilter()); - return qdef.getTable(_schema, _parseErrors, true); - } - - final public TableInfo getTable() - { - // We'll have parseErrors if we already tried and failed to create the table - if (_table != null || !_parseErrors.isEmpty()) - return _table; - _table = createTable(); - - /* TODO ContainerFilter check that this is correct for hasUnionTable() */ - if (_table instanceof ContainerFilterable && _table.supportsContainerFilter()) - { - ContainerFilter filter = getContainerFilter(); - if (filter != null) - { - // If table has a Union version, apply the filter to the Union - UserSchema userSchema = _table.getUserSchema(); - if (ContainerFilter.Type.Current != filter.getType() && null != userSchema && _table.hasUnionTable()) - { - Set containers = new HashSet<>(); - if (ContainerFilter.Type.AllFolders != filter.getType()) - { - Collection containerIds = filter.getIds(); - if (null != containerIds) - { - for (GUID id : containerIds) - containers.add(ContainerManager.getForId(id)); - } - } - else - { - containers = ContainerManager.getAllChildren(ContainerManager.getRoot()); - } - - if (!containers.isEmpty()) - _table = userSchema.getUnionTable(_table, containers); - } - } - } - - return _table; - } - - // This can be used to override the container filter that would otherwise be provided by the QuerySettings - ContainerFilter _overrideContainerFilter = null; - - public void setContainerFilter(ContainerFilter cf) - { - _overrideContainerFilter = cf; - } - - @Nullable - protected ContainerFilter getContainerFilter() - { - if (null != _overrideContainerFilter) - return _overrideContainerFilter; - - String filterName = _settings.getContainerFilterName(); - - if (filterName == null && _customView != null) - filterName = _customView.getContainerFilterName(); - - if (filterName != null) - return ContainerFilter.getContainerFilterByName(filterName, getContainer(), getUser()); - - return null; - } - - private boolean isShowExperimentalGenericDetailsURL() - { - return AppProps.getInstance().isOptionalFeatureEnabled(EXPERIMENTAL_GENERIC_DETAILS_URL); - } - - - List _queryDefDisplayColumns = null; - - public List getDisplayColumns() - { - TableInfo table = getTable(); - if (table == null) - return Collections.emptyList(); - - List ret = new ArrayList<>(); - addDetailsAndUpdateColumns(ret, table); - - if (null == _queryDefDisplayColumns) - _queryDefDisplayColumns = getQueryDef().getDisplayColumns(_customView, table); - ret.addAll(_queryDefDisplayColumns); - - if (_linkTarget != null) - { - for (DisplayColumn displayColumn : ret) - { - displayColumn.setLinkTarget(_linkTarget); - } - } - return ret; - } - - protected void addDetailsAndUpdateColumns(List ret, TableInfo table) - { - // Print view and export view don't need details and update columns, - // but the selectRows API can turn them on to include the URLs in the response format. - if (isPrintView() || isExportView()) - return; - - if (_showDetailsColumn && (null != _detailsURL || table.hasDetailsURL() || isShowExperimentalGenericDetailsURL())) - { - StringExpression urlDetails = urlExpr(QueryAction.detailsQueryRow); - - if (urlDetails != null && urlDetails != AbstractTableInfo.LINK_DISABLER) - { - // We'll decide at render time if we have enough columns in the results to make the DetailsColumn visible - DisplayColumn dc = createDetailsColumn(urlDetails, table); - if (null != dc) - ret.add(dc); - } - } - - if (_showUpdateColumn && (canUpdate() || allowQueryTableUpdateURLOverride())) - { - StringExpression urlUpdate = urlExpr(QueryAction.updateQueryRow); - if (urlUpdate != null) - { - DisplayColumn dc = createUpdateColumn(urlUpdate, table); - if (null != dc) - ret.add(0, dc); - } - } - } - - /** - * The intent of this method is to ensure that the update/details URL inherit the - * ContainerContext from the table unless explicitly set. This is relevant because QWPs can - * supply custom update/detailsURLs as a string, which has no ContainerContext. Most TableInfos - * always set the ContainerContext on the details/update URLs to ContainerContext.FieldKeyContext, - * which delegates the container to row-level (usually based on a container column). - */ - private void ensureUrlContainerContext(StringExpression se, TableInfo table) - { - if (se instanceof DetailsURL du) - { - if (!du.hasContainerContext()) - { - du.setContainerContext(table.getContainerContext()); - } - } - } - - @Nullable - protected DisplayColumn createDetailsColumn(StringExpression urlDetails, TableInfo table) - { - ensureUrlContainerContext(urlDetails, table); - - return new DetailsColumn(urlDetails, table); - } - - protected DisplayColumn createUpdateColumn(StringExpression urlUpdate, TableInfo table) - { - ensureUrlContainerContext(urlUpdate, table); - - return new UpdateColumn.Impl(urlUpdate); - } - - public QueryDefinition getQueryDef() - { - return _queryDef; - } - - public List getParseErrors() - { - return _parseErrors; - } - - public NavTrailConfig getNavTrailConfig() - { - NavTrailConfig ret = new NavTrailConfig(getRootContext()); - ret.setExtraChildren(new NavTree(getSchema().getSchemaName() + " queries", getSchema().urlFor(QueryAction.begin))); - return ret; - } - - public void setShowExportButtons(boolean showExportButtons) - { - _showExportButtons = showExportButtons; - } - - public boolean showExportButtons() - { - return _showExportButtons; - } - - public boolean showRStudioButton() - { - return _showRStudioButton; - } - - /** Currently requires showExportButtons(), or button will not be enabled */ - public void setShowRStudioButton(boolean showRStudioButton) - { - _showRStudioButton = showRStudioButton; - } - - public void setShowDetailsColumn(boolean showDetailsColumn) - { - _showDetailsColumn = showDetailsColumn; - } - - public void setShowUpdateColumn(boolean showUpdateColumn) - { - _showUpdateColumn = showUpdateColumn; - } - - public void setUpdateURL(String updateURL) - { - _updateURL = null==updateURL ? null : DetailsURL.fromString(updateURL); - } - - public void setUpdateURL(DetailsURL updateURL) - { - _updateURL = updateURL; - } - - public void setDetailsURL(String detailsURL) - { - _detailsURL = null==detailsURL ? null : DetailsURL.fromString(detailsURL); - } - - public void setDetailsURL(DetailsURL detailsURL) - { - _detailsURL = detailsURL; - } - - public void setDeleteURL(String deleteURL) - { - _deleteURL = deleteURL; - } - - public void setInsertURL(String insertURL) - { - _insertURL = insertURL; - } - - public void setImportURL(String importURL) - { - _importURL = importURL; - } - - public void setPrintView(boolean b) - { - _printView = b; - } - - public boolean isPrintView() - { - return _printView; - } - - public boolean isExportView() - { - return _exportView; - } - - public boolean isApiResponseView() - { - return _apiResponseView; - } - - public void setApiResponseView(boolean apiResponseView) - { - _apiResponseView = apiResponseView; - } - - public boolean isUseQueryViewActionExportURLs() - { - return _useQueryViewActionExportURLs; - } - - public void setUseQueryViewActionExportURLs(boolean useQueryViewActionExportURLs) - { - _useQueryViewActionExportURLs = useQueryViewActionExportURLs; - } - - public boolean isAllowExportExternalQuery() - { - return _allowExportExternalQuery; - } - - public void setAllowExportExternalQuery(boolean allowExportExternalQuery) - { - _allowExportExternalQuery = allowExportExternalQuery; - } - - public boolean isShadeAlternatingRows() - { - return _shadeAlternatingRows; - } - - public void setShadeAlternatingRows(boolean shadeAlternatingRows) - { - _shadeAlternatingRows = shadeAlternatingRows; - } - - public boolean isShowFilterDescription() - { - return _showFilterDescription; - } - - public void setShowFilterDescription(boolean showFilterDescription) - { - _showFilterDescription = showFilterDescription; - } - - public boolean isShowBorders() - { - return _showBorders; - } - - public void setShowBorders(boolean showBorders) - { - _showBorders = showBorders; - } - - public boolean isShowSurroundingBorder() - { - return _showSurroundingBorder; - } - - public void setShowSurroundingBorder(boolean showSurroundingBorder) - { - _showSurroundingBorder = showSurroundingBorder; - } - - public boolean isShowPagination() - { - return _showPagination; - } - - public void setShowPagination(boolean showPagination) - { - _showPagination = showPagination; - } - - public boolean isShowPaginationCount() - { - return _showPaginationCount; - } - - public void setShowPaginationCount(boolean showPaginationCount) - { - _showPaginationCount = showPaginationCount; - } - - /** - * controls display of the reports and charts button - */ - public boolean isShowReports() - { - // buttons can be hidden either through query settings or method overriding - return _showReports && getSettings().isShowReports(); - } - - public void setShowReports(boolean showReports) - { - _showReports = showReports; - } - - public boolean isShowConfiguredButtons() - { - return _showConfiguredButtons; - } - - public void setShowConfiguredButtons(boolean showConfiguredButtons) - { - _showConfiguredButtons = showConfiguredButtons; - } - - @NotNull - public Set getAllowableContainerFilterTypes() - { - return _allowableContainerFilterTypes; - } - - public void setAllowableContainerFilterTypes(@NotNull Collection allowableContainerFilterTypes) - { - _allowableContainerFilterTypes = Collections.unmodifiableSet(new LinkedHashSet<>(allowableContainerFilterTypes)); - } - - public void setAllowableContainerFilterTypes(ContainerFilter.Type... allowableContainerFilterTypes) - { - setAllowableContainerFilterTypes(Arrays.asList(allowableContainerFilterTypes)); - } - - public void disableContainerFilterSelection() - { - _allowableContainerFilterTypes = Collections.emptySet(); - } - - public List getAnalyticsProviders() - { - return getSettings().getAnalyticsProviders(); - } - - @NotNull - @Override - public LinkedHashSet getClientDependencies() - { - LinkedHashSet resources = new LinkedHashSet<>(); - resources.addAll(super.getClientDependencies()); - - ButtonBarConfig cfg = _buttonBarConfig; - if (cfg == null) - { - TableInfo ti = _table; - if (ti == null) - { - List errors = new ArrayList<>(); - QueryDefinition queryDef = getQueryDef(); - if (queryDef != null) - { - if (null != getContainerFilter()) - queryDef.setContainerFilter(getContainerFilter()); - ti = queryDef.getTable(getSchema(), errors, true, false); - } - } - - if (ti != null) - cfg = ti.getButtonBarConfig(); - } - - if (cfg != null && cfg.getScriptIncludes() != null) - { - for (String script : cfg.getScriptIncludes()) - { - resources.add(ClientDependency.fromPath(script)); - } - } - - List displayColumns = getDisplayColumns(); - - if (null != displayColumns) - { - for (DisplayColumn dc : displayColumns) - { - resources.addAll(dc.getClientDependencies()); - } - } - - return resources; - } - - public void setMessageSupplier(DataRegion.MessageSupplier messageSupplier) - { - _messageSupplier = messageSupplier; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.api.query; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiQueryResponse; +import org.labkey.api.admin.notification.NotificationService; +import org.labkey.api.attachments.ByteArrayAttachmentFile; +import org.labkey.api.compliance.ComplianceService; +import org.labkey.api.data.AbstractTableInfo; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.AnalyticsProviderItem; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ButtonBarConfig; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerFilterable; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DetailsColumn; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.HtmlExportWriter; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.PanelButton; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.Results; +import org.labkey.api.data.ResultsImpl; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.TSVGridWriter; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.UpdateColumn; +import org.labkey.api.data.dialect.SqlDialect; +import org.labkey.api.exp.RawValueColumn; +import org.labkey.api.query.snapshot.QuerySnapshotService; +import org.labkey.api.reports.Report; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.report.ReportUrls; +import org.labkey.api.reports.report.r.RReport; +import org.labkey.api.reports.report.view.ReportUtil; +import org.labkey.api.reports.report.view.RunReportView; +import org.labkey.api.reports.report.view.ScriptReportBean; +import org.labkey.api.rstudio.RStudioService; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.ResourceURL; +import org.labkey.api.study.UnionTable; +import org.labkey.api.study.reports.CrosstabReport; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.StringExpressionFactory; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.GridView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTrailConfig; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.PopupMenuView; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.ClientDependency; +import org.labkey.api.visualization.GenericChartReport; +import org.labkey.api.visualization.TimeChartReport; +import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.HtmlWriter; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URISyntaxException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.stream.Collectors; + +import static org.labkey.api.action.SpringActionController.ERROR_MSG; +import static org.labkey.api.util.DOM.P; +import static org.labkey.api.util.DOM.cl; + +/** + * View that generates the majority of standard data grids/tables in the LabKey Server UI. + * The backing query is lazily invoked when it comes time to render the QueryView. + */ +public class QueryView extends WebPartView implements ContainerUser +{ + public static final String EXPERIMENTAL_GENERIC_DETAILS_URL = "generic-details-url"; + + public static final String EXCEL_WEB_QUERY_EXPORT_TYPE = "excelWebQuery"; + public static final String DATAREGIONNAME_DEFAULT = "query"; + + private static final Logger _log = LogManager.getLogger(QueryView.class); + private static final Map _exportScriptFactories = new ConcurrentSkipListMap<>(); + + protected static final String INSERT_DATA_TEXT = "Insert Data"; + protected static final String INSERT_ROW_TEXT = "Insert New Row"; + protected static final String IMPORT_BULK_DATA_TEXT = "Import Bulk Data"; + + protected DataRegion.ButtonBarPosition _buttonBarPosition = DataRegion.ButtonBarPosition.TOP; + private ButtonBarConfig _buttonBarConfig = null; + private boolean _showDetailsColumn = true; + private boolean _showUpdateColumn = true; + private DataRegion.MessageSupplier _messageSupplier; + + private String _linkTarget; + + // Overrides for any URLs that might already be set on the TableInfo + private DetailsURL _updateURL; + private DetailsURL _detailsURL; + private String _insertURL; + private String _importURL; + private String _deleteURL; + + private boolean _hasExportRStudioPanel = false; + + + public static void register(ExportScriptFactory factory) + { + register(factory, false); + } + + public static void register(ExportScriptFactory factory, boolean overrideBaseFactory) + { + if (!overrideBaseFactory) + assert null == _exportScriptFactories.get(factory.getScriptType()); + + _exportScriptFactories.put(factory.getScriptType(), factory); + } + + public static ExportScriptFactory getExportScriptFactory(String type) + { + return _exportScriptFactories.get(type); + } + + static public QueryView create(ViewContext context, UserSchema schema, QuerySettings settings, BindException errors) + { + return schema.createView(context, settings, errors); + } + + static public QueryView create(QueryForm form, BindException errors) + { + form.ensureSchemaExists(); + + return create(form.getViewContext(), form.getSchema(), form.getQuerySettings(), errors); + } + + private QueryDefinition _queryDef; + private CustomView _customView; + private UserSchema _schema; + private Errors _errors; + private final List _parseErrors = new ArrayList<>(); + private QuerySettings _settings; + private boolean _showRecordSelectors = false; + + private boolean _shadeAlternatingRows = true; + private boolean _showFilterDescription = true; + private boolean _showBorders = true; + private boolean _showSurroundingBorder = true; + private Report _report; + + private boolean _showExportButtons = true; + private boolean _showRStudioButton = false; // might want show by default if rstudio is configured + private boolean _showInsertNewButton = true; + private boolean _showImportDataButton = true; + private boolean _showDeleteButton = true; + private boolean _showDeleteButtonConfirmationText = true; + private boolean _showConfiguredButtons = true; + private boolean _allowExportExternalQuery = true; + + private static final Set STANDARD_CONTAINER_FILTERS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders))); + + /** The container filters (called "Folder Filter" in the UI) that should be available to users in the Views menu */ + @NotNull + private Set _allowableContainerFilterTypes = STANDARD_CONTAINER_FILTERS; + private boolean _useQueryViewActionExportURLs = false; + private boolean _printView = false; + private boolean _exportView = false; + private boolean _apiResponseView = false; + private boolean _showPagination = true; + private boolean _showPaginationCount = true; + private boolean _showReports = true; + private ReportService.ItemFilter _itemFilter = DEFAULT_ITEM_FILTER; + + public static ReportService.ItemFilter DEFAULT_ITEM_FILTER = (type, label) -> + { + if (ReportService.get().getGlobalItemFilterTypes().contains(type)) return true; + if (RReport.TYPE.equals(type)) return true; + return CrosstabReport.TYPE.equals(type); + }; + + private TableInfo _table; + + public QueryView(QueryForm form, Errors errors) + { + this(form.getSchema(), form.getQuerySettings(), errors); + } + + + /** + * Must call setSettings before using the view + */ + public QueryView(UserSchema schema) + { + super(FrameType.DIV); + setSchema(schema); + } + + @Override + public void setTitle(CharSequence title) + { + super.setTitle(title); + if (StringUtils.isNotEmpty(title) && getFrame()==FrameType.DIV) + setFrame(FrameType.PORTAL); + } + + + /** Use the constructor that takes an Errors object instead */ + @Deprecated + protected QueryView(UserSchema schema, QuerySettings settings) + { + this(schema, settings, null); + } + + public QueryView(UserSchema schema, QuerySettings settings, @Nullable Errors errors) + { + this(schema); + // TODO: stop passing in null Errors. For now, new one up if null. + _errors = errors != null ? errors : new BindException(new Object(), "form"); + if (null != settings) + setSettings(settings); + } + + public QuerySettings getSettings() + { + return _settings; + } + + + protected void setSettings(QuerySettings settings) + { + if (null != _settings || null == _schema) + throw new IllegalStateException(); + _settings = settings; + _queryDef = settings.getQueryDef(_schema); + // Disable external exports (scripts, etc) since they will run in a different HTTP session that doesn't + // have access to the temporary query + if (_queryDef != null) + { + _allowExportExternalQuery &= !_queryDef.isTemporary(); + } + _customView = settings.getCustomView(getViewContext(), getQueryDef()); + } + + + protected int getMaxRows() + { + if (getShowRows() == ShowRows.NONE) + return Table.NO_ROWS; + if (getShowRows() != ShowRows.PAGINATED) + return Table.ALL_ROWS; + return getSettings().getMaxRows(); + } + + + protected long getOffset() + { + if (getShowRows() != ShowRows.PAGINATED) + return 0; + return getSettings().getOffset(); + } + + protected ShowRows getShowRows() + { + return getSettings().getShowRows(); + } + + protected String getSelectionKey() + { + return getSettings().getSelectionKey(); + } + + /** + * Returns an ActionURL for the "returnUrl" parameter or the current ActionURL if none. + */ + public URLHelper getReturnUrl() + { + return getSettings().getReturnUrlHelper(ViewServlet.getRequestURL()); + } + + protected boolean verboseErrors() + { + return true; + } + + + protected boolean ignoreUserFilter() + { + return (getViewContext().getRequest() != null && getViewContext().getRequest().getParameter(param(QueryParam.ignoreFilter)) != null) || + (getSettings() != null && getSettings().getIgnoreUserFilter()); + } + + // ignores filters on the custom view but not those added through query settings + protected boolean ignoreViewFilter() + { + return getSettings() != null && getSettings().getIgnoreViewFilter(); + } + + protected void renderErrors(HtmlWriter out, String message, List errors) + { + boolean isEditable = getQueryDef() != null && getQueryDef().canEdit(getUser()) && getQueryDef().isSqlEditable(); + P( + cl("labkey-error"), + message, + isEditable ? HtmlString.NBSP : null, + isEditable ? LinkBuilder.simpleLink("Edit Query", Objects.requireNonNull(getSchema().urlFor(QueryAction.sourceQuery, getQueryDef()))) : null + ).appendTo(out); + + Set seen = new HashSet<>(); + + if (verboseErrors()) + { + for (Throwable e : errors) + { + if (e instanceof QueryParseException) + { + out.write(e.getMessage()); + } + else + { + out.write(e.toString()); + } + + String resolveURL = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveURL); + if (null != resolveURL && seen.add(resolveURL)) + { + String resolveText = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveText); + if (getUser().isPlatformDeveloper()) + { + out.write(" "); + out.write(LinkBuilder.labkeyLink(Objects.toString(resolveText, "resolve"), resolveURL)); + } + } + out.write(HtmlString.BR); + } + } + } + + /* delay load menu, because it is usually visible==false */ + private class QueryNavTreeMenuButton extends MenuButton + { + private boolean populated = false; + + QueryNavTreeMenuButton(String label) + { + super(label); + setVisible(false); + } + + @Override + public void setVisible(boolean visible) + { + if (visible && !populated) + { + populateMenu(); + populated = true; + } + super.setVisible(visible); + } + + private void populateMenu() + { + if (getQueryDef() != null) + { + NavTree editQueryItem; + if (getQueryDef().isSqlEditable() && getQueryDef().canEdit(getUser())) + editQueryItem = new NavTree("Edit Source", getSchema().urlFor(QueryAction.sourceQuery, getQueryDef())); + else + editQueryItem = new NavTree("View Definition", getSchema().urlFor(QueryAction.schemaBrowser, getQueryDef())); + addMenuItem(editQueryItem); + + if (getQueryDef().isMetadataEditable() && getQueryDef().canEditMetadata(getUser())) + { + NavTree editMetadataItem = new NavTree("Edit Metadata", getSchema().urlFor(QueryAction.metadataQuery, getQueryDef())); + addMenuItem(editMetadataItem); + } + } + + addSeparator(); + + if (getSchema().shouldRenderTableList()) + { + String current = getQueryDef() != null ? getQueryDef().getName() : null; + URLHelper target = urlRefreshQuery(); + + for (QueryDefinition query : getSchema().getTablesAndQueries(true)) + { + String name = query.getName(); + NavTree item = new NavTree(name, target.clone().replaceParameter(param(QueryParam.queryName), name)); + // Intentionally don't set the description so we can avoid having to instantiate all of the TableInfos, + // which can be expensive for some schemas + if (name.equals(current)) + item.setStrong(true); + item.setImageSrc(new ResourceURL("/reports/grid.gif")); + item.setImageCls("fa fa-table"); + addMenuItem(item); + } + } + else + { + ActionURL schemaBrowserURL = PageFlowUtil.urlProvider(QueryUrls.class).urlSchemaBrowser(getContainer(), getSchema().getName()); + addMenuItem("Schema Browser", schemaBrowserURL); + } + } + } + + + public MenuButton createQueryPickerButton(String label) + { + return new QueryNavTreeMenuButton(label); + } + + + @Override + public User getUser() + { + return _schema.getUser(); + } + + public UserSchema getSchema() + { + return _schema; + } + + protected void setSchema(UserSchema schema) + { + if (null != _settings || null != _schema) + throw new IllegalStateException(); + _schema = schema; + } + + @Override + public Container getContainer() + { + return _schema.getContainer(); + } + + protected StringExpression urlExpr(QueryAction action) + { + StringExpression expr = switch (action) + { + case detailsQueryRow -> _detailsURL; + case updateQueryRow -> _updateURL; + default -> null; + + // NOTE: details/update URL may not get picked up from TableInfo if subclass overrides createTable() + // but that case should use QueryView.setDetailsURL/setUpdateURL() anyway + }; + + if (null == expr) + expr = getQueryDef().urlExpr(action, _schema.getContainer()); + + if (expr == null) + return null; + + // Don't append the returnUrl parameter in API responses + if (!isApiResponseView()) + { + switch (action) + { + case detailsQueryRow: + case updateQueryRow: + case insertQueryRow: + case importData: + case updateQueryRows: + case deleteQueryRows: + { + // ICK + URLHelper returnUrl = getReturnUrl(); + if (returnUrl != null) + { + String encodedReturnURL = PageFlowUtil.encode(returnUrl.getLocalURIString()); + expr = ((StringExpressionFactory.AbstractStringExpression) expr).addParameter(ActionURL.Param.returnUrl.name(), encodedReturnURL); + } + } + } + } + + return expr; + } + + @Nullable + protected ActionURL urlFor(QueryAction action) + { + ActionURL ret = null; + switch (action) + { + case deleteQueryRows: + if (null != _deleteURL) + ret = DetailsURL.fromString(_deleteURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + case detailsQueryRow: + // TODO kinda suspect... since this is a per-row url + if (null != _detailsURL) + ret = _detailsURL.getActionURL(); + break; + case updateQueryRow: + // TODO also kinda suspect... + if (null != _updateURL) + ret = _updateURL.getActionURL(); + break; + case insertQueryRow: + if (null != _insertURL) + ret = DetailsURL.fromString(_insertURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + case importData: + if (null != _importURL) + ret = DetailsURL.fromString(_importURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + } + + if (null == ret && null != getQueryDef()) + ret = _schema.urlFor(action, getQueryDef()); + + if (ret == null) + { + return null; + } + + // Issue 11280: Export URLs don't include the query's base sort/filter. + // The solution is to expand the custom view's saved sort/filter before adding the base sort/filter. + // NOTE: This is a temporary solution. + // + // We won't need to expand the saved custom view filters or analyticsProviders. Filters can be applied + // in any order and the analyticsProviders don't make much sense in the exported xls or tsv files. + // + // The correct long term solution is to (a) create proper QueryView subclasses using UserSchema.createView() + // and (b) use POST instead of GET for the export actions (or others) to match the LABKEY.QueryWebPart config behavior. + // Using POST is necessary since the LABKEY.QueryWebPart config expresses other options (column lists, grid rendering options, etc) that can't be expressed on URLs. + // + // Issue 17313: Exporting from a grid should respect "Apply View Filter" state + if (_customView != null) + { + if (_customView.getName() != null) + ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.viewName, _customView.getName()); + + if (!ignoreUserFilter() && _customView != null && _customView.hasFilterOrSort()) + { + _customView.applyFilterAndSortToURL(ret, DATAREGIONNAME_DEFAULT); + } + } + + // Applying the base sort/filter to the url is lossy in that anyone consuming the url can't + // determine if the sort/filter originated from QuerySettings or from a user applied sort/filter. + getSettings().getBaseFilter().applyToURL(ret, DATAREGIONNAME_DEFAULT); + + if (!getSettings().getBaseSort().getSortList().isEmpty()) + getSettings().getBaseSort().applyToURL(ret, DATAREGIONNAME_DEFAULT, true); + + switch (action) + { + case deleteQuery: + case sourceQuery: + break; + case detailsQueryRow: + case updateQueryRow: + case insertQueryRow: + case importData: + case updateQueryRows: + case deleteQueryRows: + ret.addReturnUrl(getReturnUrl()); + break; + case editSnapshot: + ret.addParameter("snapshotName", getSettings().getQueryName()); + case createSnapshot: + + case exportRowsExcel: + case exportRowsXLSX: + case exportRowsTsv: + case exportScript: + case signRowsExcel: + case signRowsXLSX: + case signRowsTsv: + case selectAll: + case printRows: + { + if (_useQueryViewActionExportURLs) + { + ret = getViewContext().cloneActionURL(); + ret.addParameter("exportType", action.name()); + ret.addParameter("dataRegionName", getExportRegionName()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(getExportRegionName() + ".maxRows"); + ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); + break; + } + ActionURL expandedURL = getViewContext().cloneActionURL(); + addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); + // Copy the other parameters that aren't scoped to the data region as well. Some exports may use them. + // For example, see issue 15451 + for (Map.Entry entry : expandedURL.getParameterMap().entrySet()) + { + String name = entry.getKey(); + // schemaName isn't prefixed with the data region name, and don't specify a special data region name + if (!name.equals("schemaName") && !name.equals("dataRegionName") && !name.startsWith(getDataRegionName() + ".") && !name.startsWith(DATAREGIONNAME_DEFAULT + ".")) + { + for (String value : entry.getValue()) + { + ret.addParameter(entry.getKey(), value); + } + } + } + + ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.selectionKey, getSelectionKey()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); + ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); + break; + } + case excelWebQueryDefinition: + { + if (_useQueryViewActionExportURLs) + { + ActionURL expandedURL = getViewContext().cloneActionURL(); + expandedURL.addParameter("exportType", EXCEL_WEB_QUERY_EXPORT_TYPE); + expandedURL.addParameter("exportRegion", getDataRegionName()); + ret.addParameter("queryViewActionURL", expandedURL.getLocalURIString()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(getExportRegionName() + ".maxRows"); + ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); + break; + } + ActionURL expandedURL = getViewContext().cloneActionURL(); + addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); + ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); + break; + } + case createRReport: + ScriptReportBean bean = new ScriptReportBean(); + bean.setReportType(RReport.TYPE); + bean.setSchemaName(_schema.getSchemaName()); + bean.setQueryName(getSettings().getQueryName()); + bean.setViewName(getSettings().getViewName()); + bean.setDataRegionName(getDataRegionName()); + + bean.setRedirectUrl(getReturnUrl().getLocalURIString()); + return ReportUtil.getScriptReportDesignerURL(_viewContext, bean); + } + return ret; + } + + protected ActionButton actionButton(String label, QueryAction action) + { + return actionButton(label, action, null, null); + } + + protected ActionButton actionButton(String label, QueryAction action, @Nullable String parameterToAdd, @Nullable String parameterValue) + { + ActionURL url = urlFor(action); + if (url == null) + { + return null; + } + if (parameterToAdd != null) + url.addParameter(parameterToAdd, parameterValue); + return new ActionButton(label, url); + } + + protected String param(QueryParam param) + { + return param(param.toString()); + } + + protected String param(String param) + { + return getDataRegionName() + "." + param; + } + + protected URLHelper urlRefreshQuery() + { + URLHelper ret = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + ret = ret.clone(); + ret.deleteParameter(param(QueryParam.queryName)); + ret.deleteParameter(param(QueryParam.viewName)); + ret.deleteParameter(param(QueryParam.reportId)); + for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) + { + ret.deleteFilterParameters(key); + } + return ret; + } + + protected ActionURL urlBaseView() + { + ActionURL ret = getSettings().getSortFilterURL(); + for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) + { + ret.deleteFilterParameters(key); + } + ret.deleteParameter(DataRegion.LAST_FILTER_PARAM); + return ret; + } + + protected URLHelper urlChangeView() + { + URLHelper ret = getSettings().getReturnUrlHelper(); + if (null == ret) + { + ret = getSettings().getSortFilterURL(); + } + else if (getSettings().getDataRegionName() != null) + { + ret = ret.clone(); + // if we are using a returnUrl for this QV, make sure we apply any sort and filter + // parameters so that reports stay in sync with the data region. + URLHelper url = getSettings().getSortFilterURL(); + for (String param : url.getKeysByPrefix(getSettings().getDataRegionName())) + { + ret.replaceParameter(param, url.getParameter(param)); + } + } + else + { + ret = ret.clone(); + } + + ret.deleteParameter(param(QueryParam.viewName)); + ret.deleteParameter(param(QueryParam.reportId)); + ret.deleteParameter(RunReportView.CACHE_PARAM); + ret.deleteParameter(RunReportView.TAB_PARAM); + return ret; + } + + protected void addParamsByPrefix(ActionURL target, ActionURL source, String oldPrefix, String newPrefix) + { + for (String key : source.getKeysByPrefix(oldPrefix)) + { + String suffix = key.substring(oldPrefix.length()); + String newKey = newPrefix + suffix; + for (String value : source.getParameterValues(key)) + { + boolean isQueryParam = false; + try + { + Enum.valueOf(QueryParam.class, suffix); + isQueryParam = true; + } + catch (Exception ignore) { } + + if (suffix.equals("sort")) + { + // Prepend source sort parameter before target's existing sort + String targetSort = target.getParameter(key); + if (targetSort != null && !targetSort.isEmpty()) + value = value + "," + targetSort; + target.replaceParameter(newKey, value); + } + else if (isQueryParam) + { + // Issue 20779: Error: Query 'Containers,Containers' in schema 'core' doesn't exist + // Issue 21101: Cannot export QueryWebPart views using a custom sql query to Excel file + // Only a single non-empty value is accepted for query parameters -- overwrite the existing parameter so we don't have duplicate parameters. + if (value != null && !value.isEmpty()) + target.replaceParameter(newKey, value); + } + else + { + target.addParameter(newKey, value); + } + } + } + } + + protected boolean canInsert() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), InsertPermission.class) && table.getUpdateService() != null; + } + + protected boolean canUpdate() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), UpdatePermission.class) && table.getUpdateService() != null; + } + + protected boolean canDelete() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), DeletePermission.class); + } + + protected boolean isAdmin() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), AdminPermission.class); + } + + private boolean allowQueryTableInsertURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasInsertURLOverride() && table.allowQueryTableURLOverrides(); + } + + protected boolean allowQueryTableUpdateURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasUpdateURLOverride() && table.allowQueryTableURLOverrides(); + } + + private boolean allowQueryTableDeleteURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasDeleteURLOverride() && table.allowQueryTableURLOverrides(); + } + + public boolean showInsertNewButton() + { + return _showInsertNewButton; + } + + public void setShowInsertNewButton(boolean showInsertNewButton) + { + _showInsertNewButton = showInsertNewButton; + } + + public boolean showImportDataButton() + { + return _showImportDataButton; + } + + public void setShowImportDataButton(boolean show) + { + _showImportDataButton = show; + } + + public boolean showDeleteButton() + { + return _showDeleteButton; + } + + public void setShowDeleteButton(boolean showDeleteButton) + { + _showDeleteButton = showDeleteButton; + } + + public boolean showDeleteButtonConfirmationText() + { + return _showDeleteButtonConfirmationText; + } + + public void setShowDeleteButtonConfirmationText(boolean showDeleteButtonConfirmationText) + { + _showDeleteButtonConfirmationText = showDeleteButtonConfirmationText; + } + + public boolean showRecordSelectors() + { + return _showRecordSelectors; + } + + /** + * Show record selectors usually doesn't need to be explicitly set. If the ButtonBar contains + * a button that requires selection, the record selectors will be added. + */ + public void setShowRecordSelectors(boolean showRecordSelectors) + { + _showRecordSelectors = showRecordSelectors; + } + + protected void populateReportButtonBar(ButtonBar bar) + { + MenuButton queryButton = createQueryPickerButton("Query"); + queryButton.setVisible(getSettings().getAllowChooseQuery()); + bar.add(queryButton); + + if (getSettings().getAllowChooseView()) + { + bar.add(createViewButton(_itemFilter)); + populateChartsReports(bar); + } + + if (showExportButtons()) + { + ActionButton b = createPrintButton(); + if (null != b) + bar.add(b); + } + } + + protected void populateButtonBar(DataView view, ButtonBar bar) + { + MenuButton queryButton = createQueryPickerButton("Query"); + queryButton.setVisible(getSettings().getAllowChooseQuery()); + bar.add(queryButton); + + if (getSettings().getAllowChooseView()) + { + bar.add(createViewButton(_itemFilter)); + } + + populateChartsReports(bar); + + if ((canInsert() || allowQueryTableInsertURLOverride()) && (showInsertNewButton() || showImportDataButton())) + { + bar.add(createInsertMenuButton()); + } + + if ((canDelete() || allowQueryTableDeleteURLOverride()) && showDeleteButton()) + { + bar.add(createDeleteButton()); + } + + if (showExportButtons()) + { + List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); + + PanelButton b = createExportButton(recordSelectorColumns); + if (b.hasSubPanels()) + { + // Issue 24530: Add record selectors for exporting selected items. Assumes that all export panels support selection. + if ((recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || (getTable() != null && !getTable().getPkColumns().isEmpty())) + { + bar.setAlwaysShowRecordSelectors(true); + } + bar.add(b); + } + + ActionButton rs = createExportToRStudioButton(); + if (null != rs) + bar.add(rs); + } + } + + @Nullable ActionButton createExportToRStudioButton() + { + ActionButton rstudio = new ActionButton("RStudio"); + String script = DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleButtonPanel('export','rstudio'); return false;"; + rstudio.setScript(script, false); + rstudio.setVisible(showRStudioButton()); + rstudio.setEnabled(_hasExportRStudioPanel); + rstudio.setDisplayPermission(ReadPermission.class); + return rstudio; + } + + @Nullable + public ActionButton createEditMultipleButton() + { + ActionButton btn = null; + ActionURL editMultipleURL = urlFor(QueryAction.updateQueryRows); + if (editMultipleURL != null) + { + editMultipleURL.addParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY, _settings.getSelectionKey()); + btn = new ActionButton(editMultipleURL, "Edit Multiple"); + btn.setActionType(ActionButton.Action.POST); + btn.setDisplayPermission(UpdatePermission.class); + btn.setRequiresSelection(true, 2, null); + } + return btn; + } + + @Nullable + public ActionButton createDeleteButton() + { + return createDeleteButton(showDeleteButtonConfirmationText()); + } + + public ActionButton createDeleteButton(boolean showConfirmation) + { + ActionURL urlDelete = urlFor(QueryAction.deleteQueryRows); + if (urlDelete != null) + { + ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); + btnDelete.setIconCls("trash"); + btnDelete.setActionType(ActionButton.Action.POST); + btnDelete.setDisplayPermission(DeletePermission.class); + if (showConfirmation) + btnDelete.setRequiresSelection(true, "Are you sure you want to delete the selected row?", "Are you sure you want to delete the selected rows?"); + else + btnDelete.setRequiresSelection(true); + return btnDelete; + } + return null; + } + + public ActionButton createInsertMenuButton() + { + return createInsertMenuButton(null, null); + } + + public ActionButton createInsertMenuButton(ActionURL overrideInsertUrl, ActionURL overrideImportUrl) + { + MenuButton button = new MenuButton("Insert"); + button.setTooltip(getInsertButtonText(INSERT_DATA_TEXT)); + button.setIconCls("plus"); + boolean hasInsertNewOption = false; + boolean hasImportDataOption = false; + + if (showInsertNewButton()) + { + ActionURL urlInsert = overrideInsertUrl == null ? urlFor(QueryAction.insertQueryRow) : overrideInsertUrl; + if (urlInsert != null) + { + NavTree insertNew = new NavTree(getInsertButtonText(getInsertButtonText(INSERT_ROW_TEXT)), urlInsert); + button.addMenuItem(insertNew); + hasInsertNewOption = true; + } + } + + if (showImportDataButton()) + { + ActionURL urlImport = overrideImportUrl == null ? urlFor(QueryAction.importData) : overrideImportUrl; + if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) + { + NavTree importData = new NavTree(getInsertButtonText(IMPORT_BULK_DATA_TEXT), urlImport); + button.addMenuItem(importData); + hasImportDataOption = true; + } + } + + return hasInsertNewOption && hasImportDataOption? button : hasInsertNewOption ? createInsertButton() : hasImportDataOption ? createImportButton() : null; + } + + public ActionButton createInsertButton() + { + ActionURL urlInsert = urlFor(QueryAction.insertQueryRow); + if (urlInsert != null) + { + ActionButton btnInsert = new ActionButton(urlInsert, getInsertButtonText(INSERT_ROW_TEXT)); + btnInsert.setActionType(ActionButton.Action.LINK); + btnInsert.setTooltip(getInsertButtonText(INSERT_ROW_TEXT)); + btnInsert.setIconCls("plus"); + return btnInsert; + } + return null; + } + + public ActionButton createImportButton() + { + ActionURL urlImport = urlFor(QueryAction.importData); + if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) + { + ActionButton btnInsert = new ActionButton(urlImport, getInsertButtonText(IMPORT_BULK_DATA_TEXT)); + btnInsert.setActionType(ActionButton.Action.LINK); + btnInsert.setTooltip(getInsertButtonText(IMPORT_BULK_DATA_TEXT)); + btnInsert.setIconCls("plus"); + return btnInsert; + } + return null; + } + + protected String getInsertButtonText(String btnTxt) + { + return StringUtils.capitalize(btnTxt.toLowerCase()); + } + + @Nullable + protected ActionButton createPrintButton() + { + ActionButton btnPrint = actionButton("Print", QueryAction.printRows); + if (null == btnPrint) + return null; + btnPrint.setIconCls("print"); + btnPrint.setTarget("_blank"); + return btnPrint; + } + + private ActionButton createShareButton(@NotNull ActionURL url, @Nullable String tooltip) + { + ActionButton shareBtn = new ActionButton(url, "Share"); + shareBtn.setActionType(ActionButton.Action.LINK); + shareBtn.setIconCls("share"); + if (tooltip != null) + shareBtn.setTooltip(tooltip); + + return shareBtn; + } + + /** + * Make all links rendered in columns target the specified browser window/tab + */ + public void setLinkTarget(String linkTarget) + { + _linkTarget = linkTarget; + } + + public abstract static class ExportOptionsBean + { + private final String _dataRegionName; + private final String _exportRegionName; + private final String _selectionKey; + private final ColumnHeaderType _headerType; + private final boolean _includeSignButton; + private final String _email; + + protected ExportOptionsBean(String dataRegionName, String exportRegionName, @Nullable String selectionKey, + ColumnHeaderType headerType, boolean includeSignButton, @Nullable String email) + { + _dataRegionName = dataRegionName; + _exportRegionName = exportRegionName; + _selectionKey = selectionKey; + _headerType = headerType; + _includeSignButton = includeSignButton; + _email = email; + } + + public String getDataRegionName() + { + return _dataRegionName; + } + + public String getExportRegionName() + { + return _exportRegionName; + } + + @Nullable + public String getSelectionKey() + { + return _selectionKey; + } + + /** @return false if the region won't support row selectors, usually because it doesn't have a primary key */ + public boolean isSelectable() + { + return _selectionKey != null; + } + + public boolean hasSelected(ViewContext context) + { + if (!isSelectable()) + { + return false; + } + Set selected = DataRegionSelection.getSelected(context, _selectionKey, false); + return !selected.isEmpty(); + } + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public boolean isIncludeSignButton() + { + return _includeSignButton; + } + + public String getEmail() + { + return _email; + } + } + + public static class ExcelExportOptionsBean extends ExportOptionsBean + { + private final ActionURL _xlsURL; + private final ActionURL _xlsxURL; + private final ActionURL _iqyURL; + private final ActionURL _signXlsURL; + private final ActionURL _signXlsxURL; + + public ExcelExportOptionsBean( + String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, + ActionURL xlsURL, ActionURL xlsxURL, ActionURL iqyURL, ActionURL signXlsURL, ActionURL signXlsxURL, @Nullable String email) + { + super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signXlsURL && null != signXlsxURL), email); + _xlsURL = xlsURL; + _xlsxURL = xlsxURL; + _iqyURL = iqyURL; + _signXlsURL = null != signXlsURL ? signXlsURL : new ActionURL(); + _signXlsxURL = null != signXlsxURL ? signXlsxURL : new ActionURL(); + } + + @NotNull + public ActionURL getXlsxURL() + { + return _xlsxURL; + } + + public ActionURL getIqyURL() + { + return _iqyURL; + } + + @NotNull + public ActionURL getXlsURL() + { + return _xlsURL; + } + + @NotNull + public ActionURL getSignXlsURL() + { + return _signXlsURL; + } + + @NotNull + public ActionURL getSignXlsxURL() + { + return _signXlsxURL; + } + } + + public static class TextExportOptionsBean extends ExportOptionsBean + { + private final ActionURL _tsvURL; + private final ActionURL _signTsvURL; + + public TextExportOptionsBean( + String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, + ActionURL tsvURL, ActionURL signTsvURL, @Nullable String email) + { + super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signTsvURL), email); + _tsvURL = tsvURL; + _signTsvURL = null != signTsvURL ? signTsvURL : new ActionURL(); + } + + @NotNull + public ActionURL getTsvURL() + { + return _tsvURL; + } + + @NotNull + public ActionURL getSignTsvURL() + { + return _signTsvURL; + } + } + + @NotNull + public PanelButton createExportButton(@Nullable List recordSelectorColumns) + { + String buttonText = "Export"; + ActionURL signRowsXlsURL = null; + ActionURL signRowsXlsxURL = null; + ActionURL signRowsTsvURL = null; + ComplianceService complianceService = ComplianceService.get(); + if (complianceService.hasElecSignPermission(getContainer(), getUser()) && !getUser().isImpersonated()) + { + // We build a URL using Query's mechanism because it does a lot of work to get the properties right; + // Then build our URL to the ComplianceController using those properties. If any fail, just bail on creating button. + signRowsXlsURL = complianceService.urlFor(getContainer(), QueryAction.signRowsExcel, urlFor(QueryAction.signRowsExcel)); + signRowsXlsxURL = complianceService.urlFor(getContainer(), QueryAction.signRowsXLSX, urlFor(QueryAction.signRowsXLSX)); + signRowsTsvURL = complianceService.urlFor(getContainer(), QueryAction.signRowsTsv, urlFor(QueryAction.signRowsTsv)); + if (null != signRowsXlsURL && null != signRowsXlsxURL && null != signRowsTsvURL) + buttonText += " / Sign Data"; + } + + PanelButton button = new PanelButton("export", buttonText, getDataRegionName()); + button.setActionName("export"); // #32594: API can set a buttonConfig including "export"; since the caption may differ, add action so BuiltinButtonConfig can figure it out + ActionURL xlsURL = urlFor(QueryAction.exportRowsExcel); + ActionURL xlsxURL = urlFor(QueryAction.exportRowsXLSX); + ActionURL tsvURL = urlFor(QueryAction.exportRowsTsv); + + button.setIconCls("download"); + button.setTabAlignTop(true); + boolean hasRecordSelectors = (recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || + (getTable() != null && !getTable().getPkColumns().isEmpty()); + + if (xlsURL != null && xlsxURL != null) + { + ExcelExportOptionsBean excelBean = new ExcelExportOptionsBean( + getDataRegionName(), + getExportRegionName(), + hasRecordSelectors ? getSettings().getSelectionKey() : null, + getColumnHeaderType(), + xlsURL, + xlsxURL, + _allowExportExternalQuery ? urlFor(QueryAction.excelWebQueryDefinition) : null, + signRowsXlsURL, + signRowsXlsxURL, + getUser().getEmail() + ); + button.addSubPanel("Excel", new JspView<>("/org/labkey/api/query/excelExportOptions.jsp", excelBean)); + } + + if (tsvURL != null) + { + TextExportOptionsBean textBean = new TextExportOptionsBean( + getDataRegionName(), + getExportRegionName(), + hasRecordSelectors ? getSettings().getSelectionKey() : null, + getColumnHeaderType(), + tsvURL, + signRowsTsvURL, + getUser().getEmail() + ); + button.addSubPanel("Text", new JspView<>("/org/labkey/api/query/textExportOptions.jsp", textBean)); + } + + if (_allowExportExternalQuery) + { + addExportScriptItems(button); + addExportRStudio(button, hasRecordSelectors ? getSettings().getSelectionKey() : null); + } + + return button; + } + + + public void addExportRStudio(PanelButton exportButton, String selectionKey) + { + RStudioService rss = RStudioService.get(); + if (null == rss || null == rss.getRStudioLink(getUser(), getContainer())) + return; + if (null == getExportScriptFactory("r")) + return; + ActionURL exportUrl = urlFor(QueryAction.exportScript); + if (null == exportUrl) + return; + exportUrl.replaceParameter("scriptType","r"); + TextExportOptionsBean textBean = new TextExportOptionsBean(getDataRegionName(), getExportRegionName(), selectionKey, + getColumnHeaderType(), exportUrl, null, null); + HttpView exportView = rss.getExportToRStudioView(textBean); + if (exportView == null) + return; + exportButton.addSubPanel("RStudio", exportView); + _hasExportRStudioPanel = true; + } + + + public void addExportScriptItems(PanelButton button) + { + if (!_exportScriptFactories.isEmpty()) + { + Map options = new LinkedHashMap<>(); + + for (ExportScriptFactory factory : _exportScriptFactories.values()) + { + ActionURL url = urlFor(QueryAction.exportScript); + if (null != url) + { + url.addParameter("scriptType", factory.getScriptType()); + options.put(factory.getMenuText(), url); + } + } + + if (!options.isEmpty()) + button.addSubPanel("Script", new JspView<>("/org/labkey/api/query/scriptExportOptions.jsp", options)); + } + } + + public ReportService.ItemFilter getViewItemFilter() + { + return _itemFilter; + } + + public void setViewItemFilter(ReportService.ItemFilter filter) + { + if (filter != null) + _itemFilter = filter; + } + + public MenuButton createViewButton(ReportService.ItemFilter filter) + { + setViewItemFilter(filter); + String current = null; + + // if we are not rendering a report or not showing reports, we use the current view name to set the menu item + // selection, an empty string denotes the default view, a customized default view will have a null name. + if (_report == null || !_showReports) + current = (_customView != null) ? Objects.toString(_customView.getName(), "") : ""; + + URLHelper target = urlChangeView(); + MenuButton button = new MenuButton("Grid Views"); + button.setTooltip("Grid views"); + button.setIconCls("table"); + NavTree menu = button.getNavTree(); + + if (getSettings().isAllowCustomizeView()) + addCustomizeViewItems(button); + + if (!getQueryDef().isTemporary()) + { + button.addSeparator(); + addGridViews(button, target, current); + button.addSeparator(); + addManageViewItems(button, PageFlowUtil.map( + "schemaName", getSchema().getSchemaName(), + "queryName", getSettings().getQueryName())); + addFilterItems(button); + } + + return button; + } + + protected MenuButton createReportButton() + { + MenuButton button = new MenuButton("Reports"); + NavTree menu = button.getNavTree(); + + if (!getQueryDef().isTemporary() && _report == null) + { + List reportDesigners = new ArrayList<>(); + getSettings().setSchemaName(getSchema().getSchemaName()); + + for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) + { + for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) + { + if (designerInfo.getType() != ReportService.DesignerType.VISUALIZATION) + reportDesigners.add(designerInfo); + } + } + + reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); + + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (ReportService.DesignerInfo designer : reportDesigners) + { + if (viewItemFilter.accept(designer.getReportType(), designer.getLabel())) + { + NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); + item.setImageSrc(designer.getIconURL()); + item.setImageCls(designer.getIconCls()); + + menu.addChild(item); + } + } + } + + // existing reports + if (!getQueryDef().isTemporary()) + { + addReportViews(button); + } + + return button; + } + + private MenuButton createChartButton() + { + MenuButton button = new MenuButton("Charts"); + button.setIconCls("area-chart"); + + if (!getQueryDef().isTemporary() && _report == null) + { + List reportDesigners = new ArrayList<>(); + getSettings().setSchemaName(getSchema().getSchemaName()); + + for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) + { + for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) + { + if (designerInfo.getType() == ReportService.DesignerType.VISUALIZATION) + reportDesigners.add(designerInfo); + } + } + + reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); + + for (ReportService.DesignerInfo designer : reportDesigners) + { + NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); + item.setImageSrc(designer.getIconURL()); + item.setImageCls(designer.getIconCls()); + button.addMenuItem(item); + } + } + + if (!getQueryDef().isTemporary()) + { + addChartViews(button); + } + + return button; + } + + protected void populateChartsReports(ButtonBar bar) + { + if (isShowReports()) + { + MenuButton reportButton = createReportButton(); + MenuButton chartButton = createChartButton(); + NavTree uiProviderLinks = createUIProviderLinks(); + + if (reportButton.getNavTree().hasChildren()) + { + chartButton.setTooltip("Charts / Reports"); + NavTree chartMenu = chartButton.getNavTree(); + chartMenu.addSeparator(); + for (NavTree child : reportButton.getNavTree().getChildren()) + chartButton.addMenuItem(child); + } + if (uiProviderLinks != null && uiProviderLinks.hasChildren()) + { + chartButton.addSeparator(); + for (NavTree child : uiProviderLinks.getChildren()) + chartButton.addMenuItem(child); + } + + if (chartButton.getNavTree().hasChildren()) + bar.add(chartButton); + } + } + + private NavTree createUIProviderLinks() + { + NavTree menu = null; + List uiProviders = ReportService.get().getUIProviders(); + Map> uiProviderAddedViews = new TreeMap<>(); + + for (ReportService.UIProvider provider : uiProviders) + { + for (Pair additionalItem : provider.getAdditionalChartingMenuItems(getViewContext(), getSettings())) + { + if (!uiProviderAddedViews.containsKey(additionalItem.second)) + uiProviderAddedViews.put(additionalItem.second, new ArrayList<>()); + uiProviderAddedViews.get(additionalItem.second).add(additionalItem.first); + } + } + + if (!uiProviderAddedViews.isEmpty()) + { + menu = new NavTree(); + for (Map.Entry> entry : uiProviderAddedViews.entrySet()) + { + List navItems = entry.getValue(); + navItems.sort(Comparator.comparing(NavTree::getText)); + for (NavTree item : navItems) + menu.addChild(item); + } + } + + return menu; + } + + public ReportService.ItemFilter getItemFilter() + { + QueryDefinition def = QueryService.get().getQueryDef(getUser(), getContainer(), getSchema().getSchemaName(), getSettings().getQueryName()); + if (def == null) + def = QueryService.get().createQueryDefForTable(getSchema(), getSettings().getQueryName()); + + return new WrappedItemFilter(_itemFilter, def); + } + + private static class WrappedItemFilter implements ReportService.ItemFilter + { + private final ReportService.ItemFilter _filter; + private final Map _filterItemMap = new HashMap<>(); + + + public WrappedItemFilter(ReportService.ItemFilter filter, QueryDefinition def) + { + _filter = filter; + + if (def != null) + { + for (ViewOptions.ViewFilterItem item : def.getViewOptions().getViewFilterItems()) + _filterItemMap.put(item.getViewType(), item); + } + } + + @Override + public boolean accept(String type, String label) + { + if (_filter.accept(type, label)) + { + if (_filterItemMap.containsKey(type)) + return _filterItemMap.get(type).isEnabled(); + else + return true; + } + + if (_filterItemMap.containsKey(type)) + return _filterItemMap.get(type).isEnabled(); + + return false; + } + } + + protected void addFilterItems(MenuButton button) + { + if (_customView != null && _customView.hasFilterOrSort()) + { + URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + url = url.clone(); + NavTree item; + String label = "Apply Grid Filter"; + if (ignoreUserFilter()) + { + url.deleteParameter(param(QueryParam.ignoreFilter)); + item = new NavTree(label, url); + } + else + { + url.replaceParameter(param(QueryParam.ignoreFilter), "1"); + item = new NavTree(label, url); + item.setSelected(true); + } + item.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".clearSelected({quiet: true});"); + button.addMenuItem(item); + } + + TableInfo t = getTable(); + if (t instanceof UnionTable ut) + { + t = ut.getComponentTable(); // check against a component table + } + if (null != t && t.supportsContainerFilter() && !getAllowableContainerFilterTypes().isEmpty()) + { + NavTree containerFilterItem = new NavTree("Folder Filter"); + button.addMenuItem(containerFilterItem); + + ContainerFilter selectedFilter = getContainerFilter(); + ContainerFilter.Type selectedFilterType = null != selectedFilter ? selectedFilter.getType() : ContainerFilter.Type.Current; + + for (ContainerFilter.Type filterType : getAllowableContainerFilterTypes()) + { + URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + url = url.clone(); + String propName = getDataRegionName() + DataRegion.CONTAINER_FILTER_NAME; + url.replaceParameter(propName, filterType.name()); + NavTree filterItem = new NavTree(filterType.toString(), url); + + if (selectedFilterType == filterType) + { + filterItem.setSelected(true); + } + filterItem.setNoFollow(true); + containerFilterItem.addChild(filterItem); + } + } + } + + protected String getChangeViewScript(String viewName) + { + return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'view', viewName:" + PageFlowUtil.jsString(viewName) + "});"; + } + + protected String getChangeReportScript(String reportId) + { + return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'report', reportId:" + PageFlowUtil.jsString(reportId) + "});"; + } + + protected void addGridViews(MenuButton menu, URLHelper target, String currentView) + { + List views = new ArrayList<>(getQueryDef().getCustomViews(getViewContext().getUser(), getViewContext().getRequest(), false, false).values()); + List viewItems = new ArrayList<>(); + + // default grid view stays at the top level. The default will have a getName == null + boolean hasDefault = false; + for (CustomView view : views) + { + if (view.getName() == null) + { + hasDefault = true; + break; + } + } + + // To make generating menu items easier, create a default custom view if it doesn't exist yet. + if (!hasDefault) + { + // don't pass getUser() as owner, we want the default view to appear as "public" + CustomView defaultView = getQueryDef().createCustomView(); + views.add(0, defaultView); + } + + // sort the grid view alphabetically, with default first (null name), then private views over public ones + views.sort((o1, o2) -> + { + if (o1.getName() == null) return -1; + if (o2.getName() == null) return 1; + if (!o1.isShared() && o2.isShared()) return -1; + if (o1.isShared() && !o2.isShared()) return 1; + + return o1.getName().compareToIgnoreCase(o2.getName()); + }); + + for (CustomView view : views) + { + if (view.isHidden()) + continue; + + NavTree item; + String name = view.getName(); + if (name == null) + { + String label = Objects.toString(view.getLabel(), "Default"); + + item = new NavTree(label, (ActionURL) null); + item.setScript(getChangeViewScript("")); + if ("".equals(currentView)) + item.setStrong(true); + } + else + { + String label = view.getLabel(); + + item = new NavTree(label, (ActionURL) null); + item.setScript(getChangeViewScript(name)); + if (name.equals(currentView)) + item.setStrong(true); + } + + StringBuilder description = new StringBuilder(); + if (view.isSession()) + { + item.setEmphasis(true); + description.append("Unsaved "); + } + if (view.isShared()) + description.append("Shared "); + else + description.append("Private "); + + if (view.getContainer() != null && !view.getContainer().equals(getContainer())) + description.append("Inherited from '").append(PageFlowUtil.filter(view.getContainer().getPath())).append("'"); + + if (!description.isEmpty()) + item.setDescription(description.toString()); + + try + { + URLHelper iconUrl; + if (null != view.getCustomIconUrl()) + iconUrl = new URLHelper(view.getCustomIconUrl()); + else + iconUrl = new URLHelper(view.isShared() ? "/reports/grid.gif" : "/reports/icon_private_view.png"); + iconUrl.setContextPath(AppProps.getInstance().getParsedContextPath()); + item.setImageSrc(iconUrl); + + if (null != view.getCustomIconCls()) + item.setImageCls(view.getCustomIconCls()); + } + catch (URISyntaxException e) + { + _log.error("Invalid custom view icon url", e); + } + + viewItems.add(item); + menu.addMenuItem(item); + } + + // enable menu filtering for the module list if > 10 items + if (viewItems.size() > 10) + { + String menuFilterItemCls = PopupMenuView.getMenuFilterItemCls(menu.getNavTree()); + for (NavTree item : viewItems) + item.setMenuFilterItemCls(menuFilterItemCls); + } + + } + + protected void addReportViews(MenuButton menu) + { + List allReports = new ArrayList<>(); + // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too + for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) + { + allReports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); + } + Map> views = new TreeMap<>(); + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (Report report : allReports) + { + // Filter out reports that don't match what this view is supposed to show. This can prevent + // reports that were created on the same schema and table/query from a different view from showing up on a + // view that's doing magic to add additional filters, for example. + if (viewItemFilter.accept(report.getType(), null) + && !report.getType().equals(TimeChartReport.TYPE) + && !report.getType().equals(GenericChartReport.TYPE)) + { + if (canViewReport(getUser(), getContainer(), report) && !report.getDescriptor().isHidden()) + { + if (!views.containsKey(report.getType())) + views.put(report.getType(), new ArrayList<>()); + + views.get(report.getType()).add(report); + } + } + } + + if (!views.isEmpty()) + menu.addSeparator(); + + for (Map.Entry> entry : views.entrySet()) + { + List reports = entry.getValue(); + + // sort the list of reports within each type grouping + reports.sort((o1, o2) -> + { + String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); + String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); + + return n1.compareToIgnoreCase(n2); + }); + + for (Report report : reports) + { + String reportId = report.getDescriptor().getReportId().toString(); + NavTree item = new NavTree(report.getDescriptor().getReportName(), (ActionURL) null); + if (report.getDescriptor().getReportId().equals(getSettings().getReportId())) + item.setStrong(true); + item.setImageSrc(ReportUtil.getIconUrl(getContainer(), report)); + item.setScript(getChangeReportScript(reportId)); + menu.addMenuItem(item); + } + } + } + + protected void addChartViews(MenuButton menu) + { + List reports = new ArrayList<>(); + // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too + for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) + { + reports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); + } + Map> views = new TreeMap<>(); + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (Report report : reports) + { + // Filter out reports that don't match what this view is supposed to show. This can prevent + // reports that were created on the same schema and table/query from a different view from showing up on a + // view that's doing magic to add additional filters, for example. + if (viewItemFilter.accept(report.getType(), null) && + (report.getType().equals(TimeChartReport.TYPE) || report.getType().equals(GenericChartReport.TYPE))) + { + if (canViewReport(getUser(), getContainer(), report)) + { + if (!views.containsKey(report.getType())) + views.put(report.getType(), new ArrayList<>()); + + views.get(report.getType()).add(report); + } + } + } + + if (!views.isEmpty()) + menu.addSeparator(); + + for (Map.Entry> entry : views.entrySet()) + { + List charts = entry.getValue(); + + charts.sort((o1, o2) -> + { + String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); + String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); + + return n1.compareToIgnoreCase(n2); + }); + + for (Report chart : charts) + { + String chartId = chart.getDescriptor().getReportId().toString(); + NavTree item = new NavTree(chart.getDescriptor().getReportName(), (ActionURL) null); + item.setImageSrc(ReportUtil.getIconUrl(getContainer(), chart)); + item.setImageCls(ReportUtil.getIconCls(chart)); + item.setScript(getChangeReportScript(chartId)); + + if (chart.getDescriptor().getReportId().equals(getSettings().getReportId())) + item.setStrong(true); + + menu.addMenuItem(item); + } + } + } + + protected boolean canViewReport(User user, Container c, Report report) + { + return true; + } + + public void addCustomizeViewItems(MenuButton button) + { + if (_report == null) + { + ActionURL urlTableInfo = getSchema().urlFor(QueryAction.tableInfo); + urlTableInfo.addParameter(QueryParam.queryName.toString(), getQueryDef().getName()); + + NavTree customizeView = new NavTree("Customize Grid"); + customizeView.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleShowCustomizeView();"); + customizeView.setImageCls("fa fa-pencil"); + button.addMenuItem(customizeView); + } + + if (isAdmin() && QueryService.get().isQuerySnapshot(getContainer(), getSchema().getSchemaName(), getSettings().getQueryName())) + { + QuerySnapshotService.Provider provider = QuerySnapshotService.get(getSchema().getSchemaName()); + if (provider != null) + { + NavTree item = button.addMenuItem("Edit Snapshot", provider.getEditSnapshotURL(getSettings(), getViewContext())); + } + } + } + + public void addManageViewItems(MenuButton button, Map params) + { + ActionURL url = PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(getContainer()); + for (Map.Entry entry : params.entrySet()) + url.addParameter(entry.getKey(), entry.getValue()); + + NavTree item = button.addMenuItem("Manage Views", url); + item.setImageCls("fa fa-cog"); + } + + public String getDataRegionName() + { + return getSettings().getDataRegionName(); + } + + private String getExportRegionName() + { + return _useQueryViewActionExportURLs ? getDataRegionName() : DATAREGIONNAME_DEFAULT; + } + + private String _baseId = null; + + /** + * Use this html encoded dataRegionName as the base id for menus and attribute values that need to be rendered into the DOM. + */ + protected String getBaseMenuId() + { + if (_baseId == null) + _baseId = PageFlowUtil.filter(getDataRegionName()); + return _baseId; + } + + protected String h(Object o) + { + return PageFlowUtil.filter(o); + } + + /** + * this is the choke point for rendering reports and views, if this method is overridden you need to call + * super in order to have report/view rendering to work properly. + */ + @Override + protected void renderView(Object model, HttpServletRequest request, HttpServletResponse response) throws Exception + { + if (isReportView(getViewContext())) + renderReportView(request, response); + else + renderDataRegion(HtmlWriter.of(response)); + } + + private void renderReportView(HttpServletRequest request, HttpServletResponse response) throws IOException + { + if (_report != null) + { + try + { + ReportDataRegion dr = new ReportDataRegion(getSettings(), getViewContext(), _report); + RenderContext ctx = new RenderContext(getViewContext()); + + if (!isPrintView()) + { + // not sure why this is necessary (adding the reportId to the context) + ctx.put("reportId", _report.getDescriptor().getReportId()); + + ButtonBar bar = new ButtonBar(); + populateReportButtonBar(bar); + + if (_report.allowShareButton(getUser(), getContainer())) + { + ActionURL shareUrl = PageFlowUtil.urlProvider(ReportUrls.class).urlShareReport(getContainer(), _report); + if (shareUrl != null) + bar.add(createShareButton(shareUrl, "Share report")); + } + + dr.setButtonBar(bar); + } + dr.render(ctx, request, response); + + // if the user is viewing a shared report, remove any notifications related to it + NotificationService.get().removeNotifications( + getContainer(), _report.getDescriptor().getReportId().toString(), + Collections.singletonList(Report.SHARE_REPORT_TYPE), getUser().getUserId() + ); + } + catch (Exception e) + { + renderErrors(HtmlWriter.of(response), "Error rendering report : " + _report.getDescriptor().getReportName(), Collections.singletonList(e)); + } + } + } + + protected SqlDialect getSqlDialect() + { + return getSchema().getDbSchema().getSqlDialect(); + } + + protected DataRegion createDataRegion() + { + DataRegion rgn = new DataRegion(); + configureDataRegion(rgn); + return rgn; + } + + protected void configureDataRegion(DataRegion rgn) + { + rgn.setDisplayColumns(getDisplayColumns()); + rgn.setSettings(getSettings()); + rgn.setShowRecordSelectors(showRecordSelectors()); + rgn.setSelectAllURL(urlFor(QueryAction.selectAll)); + + rgn.setShadeAlternatingRows(isShadeAlternatingRows()); + rgn.setShowFilterDescription(isShowFilterDescription()); + rgn.setShowBorders(isShowBorders()); + rgn.setShowSurroundingBorder(isShowSurroundingBorder()); + rgn.setShowPagination(isShowPagination()); + rgn.setShowPaginationCount(isShowPaginationCount()); + + if (_messageSupplier != null) + rgn.addMessageSupplier(_messageSupplier); + + if (_customView != null && _customView.getErrors() != null) + { + rgn.addMessageSupplier(dataRegion -> _customView.getErrors().stream() + .map(e -> new DataRegion.Message(e, DataRegion.MessageType.ERROR, DataRegion.MessagePart.view)) + .collect(Collectors.toList())); + } + + TableInfo table = getTable(); + if (table instanceof FilteredTable ft && ft.hasRulesOmittedColumns()) + { + rgn.addMessageSupplier(x -> List.of(new DataRegion.Message("PHI protected columns have been omitted", DataRegion.MessageType.WARNING, DataRegion.MessagePart.header))); + } + + // Allow region to specify header lock, optionally override + if (rgn.getAllowHeaderLock()) + rgn.setAllowHeaderLock(getSettings().getAllowHeaderLock()); + + rgn.setTable(table); + + if (isShowConfiguredButtons()) + { + // We first apply the button bar config from the table: + ButtonBarConfig tableBarConfig = table == null ? null : table.getButtonBarConfig(); + if (tableBarConfig != null) + rgn.addButtonBarConfig(tableBarConfig); + // Then any overriding button bar config (from javascript) is applied: + if (_buttonBarConfig != null) + rgn.addButtonBarConfig(_buttonBarConfig); + } + + if (table != null && table.getAggregateRowConfig() != null) + { + rgn.setAggregateRowConfig(table.getAggregateRowConfig()); + } + } + + public void setButtonBarPosition(DataRegion.ButtonBarPosition buttonBarPosition) + { + _buttonBarPosition = buttonBarPosition; + } + + public void setButtonBarConfig(ButtonBarConfig buttonBarConfig) + { + _buttonBarConfig = buttonBarConfig; + } + + public ButtonBarConfig getButtonBarConfig() + { + return _buttonBarConfig; + } + + private boolean isReportView(ViewContext viewContext) + { + _report = getSettings().getReportView(viewContext); + + return _report != null && StringUtils.trimToNull(getSettings().getViewName()) == null; + } + + public DataView createDataView() + { + DataRegion rgn = createDataRegion(); + + //if explicit set of fieldkeys has been set + //add those specifically to the region + if (null != getSettings().getFieldKeys()) + { + TableInfo table = getTable(); + if (table != null) + { + rgn.clearColumns(); + List keys = getSettings().getFieldKeys(); + FieldKey starKey = FieldKey.fromParts("*"); + + // include details and update columns if they've been requested + addDetailsAndUpdateColumns(rgn.getDisplayColumns(), table); + + //special-case: if one of the keys is *, add all columns from the + //TableInfo and remove the * so that Query doesn't choke on it + if (keys.contains(starKey)) + { + rgn.addColumns(table.getColumns()); + keys.remove(starKey); + // Since the client requested all columns, don't filter which ones get sent back + getSettings().setFieldKeys(null); + } + + if (!keys.isEmpty()) + { + Map selectedCols = QueryService.get().getColumns(table, keys); + for (ColumnInfo col : selectedCols.values()) + rgn.addColumn(col); + } + } + } + else if (null != getSettings().getExtraFieldKeys()) + { + TableInfo table = getTable(); + if (table != null) + { + List keys = getSettings().getExtraFieldKeys(); + if (!keys.isEmpty()) + { + Map selectedCols = QueryService.get().getColumns(table, keys); + for (ColumnInfo col : selectedCols.values()) + rgn.addColumn(col); + } + } + } + + GridView ret = new GridView(rgn, _errors); + setupDataView(ret); + return ret; + } + + protected void setupDataView(DataView ret) + { + DataRegion rgn = ret.getDataRegion(); + ret.setFrame(WebPartView.FrameType.NONE); + rgn.setAllowAsync(true); + ButtonBar bb = new ButtonBar(); + if (!(isApiResponseView() || isPrintView() || isExportView())) + { + populateButtonBar(ret, bb); + + // TODO: Until the "More" menu is dynamically populated the "Print" button has been moved back to the bar. + // Print button is rendered separately to respect ordering -- we want it rendering after all custom buttons + // added by overrides of populateButtonBar(). + // bar.add(populateMoreMenu()); + if (showExportButtons()) + bb.add(createPrintButton()); + } + rgn.setButtonBar(bb); + + rgn.setButtonBarPosition(isApiResponseView() || isPrintView() ? DataRegion.ButtonBarPosition.NONE : _buttonBarPosition); + + if (getSettings() != null && getSettings().getShowRows() == ShowRows.ALL) + { + // Don't cache if the ResultSet is likely to be very large + ret.getRenderContext().setCache(false); + } + + ActionURL customViewUrl = null; + if (_customView != null && _customView.hasFilterOrSort() && !ignoreViewFilter()) + { + customViewUrl = new ActionURL(); + _customView.applyFilterAndSortToURL(customViewUrl, getDataRegionName()); + } + + // Apply base sorts and filters from custom view and from QuerySettings. + if (!ignoreUserFilter()) + { + SimpleFilter filter; + if (ret.getRenderContext().getBaseFilter() instanceof SimpleFilter) + { + filter = (SimpleFilter) ret.getRenderContext().getBaseFilter(); + } + else + { + filter = new SimpleFilter(ret.getRenderContext().getBaseFilter()); + } + Sort sort = ret.getRenderContext().getBaseSort(); + if (sort == null) + { + sort = new Sort(); + } + + // We need to set the base sort/filter _before_ adding the customView sort/filter. + // If the user has set a sort on their custom view, we want their sort to take precedence. + filter.addAllClauses(getSettings().getBaseFilter()); + sort.insertSort(getSettings().getBaseSort()); + + if (customViewUrl != null) + { + try + { + filter.addUrlFilters(customViewUrl, getDataRegionName()); + } + catch (ConversionException e) + { + _errors.reject(ERROR_MSG, "Invalid grid view filter: " + e.getMessage()); + } + sort.addURLSort(customViewUrl, getDataRegionName()); + } + + ret.getRenderContext().setBaseFilter(filter); + ret.getRenderContext().setBaseSort(sort); + } + + // Apply analytics providers from custom view and query settings + List analyticsProviders = new LinkedList<>(); + if (ret.getRenderContext().getBaseAnalyticsProviders() != null) + analyticsProviders.addAll(ret.getRenderContext().getBaseAnalyticsProviders()); + if (getSettings().getAnalyticsProviders() != null) + analyticsProviders.addAll(getSettings().getAnalyticsProviders()); + if (customViewUrl != null) + analyticsProviders.addAll(AnalyticsProviderItem.fromURL(customViewUrl, getDataRegionName())); + ret.getRenderContext().setBaseAnalyticsProviders(analyticsProviders); + + // XXX: Move to QuerySettings? + if (_customView != null) + ret.getRenderContext().setView(_customView); + + // TODO: Don't set available container filters in render context + // 11082: Need to push list of available container filters to DataRegion.js + ret.getRenderContext().put("allowableContainerFilterTypes", getAllowableContainerFilterTypes()); + } + + + protected void renderDataRegion(HtmlWriter out) throws Exception + { + // make sure table has been instantiated + getTable(); + List errors = getParseErrors(); + if (errors.isEmpty()) + { + include(createDataView(), out.unwrap()); + } + else + { + renderErrors(out, "Query '" + getQueryDef().getName() + "' has errors", errors); + } + } + + + protected ColumnHeaderType getColumnHeaderType() + { + return ColumnHeaderType.Caption; + } + + public TSVGridWriter getTsvWriter() throws IOException + { + return getTsvWriter(getColumnHeaderType()); + } + + protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType) throws IOException + { + return getTsvWriter(headerType, Collections.emptyMap()); + } + + protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType, @NotNull Map renameColumnMap) + { + _exportView = true; + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setAllowAsync(false); + rgn.setShowPagination(false); + rgn.prepareDisplayColumns(getContainer()); + RenderContext rc = view.getRenderContext(); + rc.setCache(false); + TSVGridWriter tsv = new TSVGridWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), renameColumnMap); + tsv.setFilenamePrefix(getSettings().getQueryName() != null ? getSettings().getQueryName() : "query"); + // don't step on default + if (null != headerType) + tsv.setColumnHeaderType(headerType); + return tsv; + } + + public Results getResults() throws SQLException, IOException + { + return getResults(ShowRows.ALL); + } + + public Results getResults(ShowRows showRows) throws SQLException, IOException + { + return getResults(showRows, false, false); + } + + public Results getResults(ShowRows showRows, boolean async, boolean cache) throws SQLException, IOException + { + _exportView = true; + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + ShowRows prevShowRows = getSettings().getShowRows(); + try + { + // Set to the desired row policy + getSettings().setShowRows(showRows); + rgn.setAllowAsync(async); + view.getRenderContext().setCache(cache); + RenderContext ctx = view.getRenderContext(); + if (null == rgn.getResults(ctx)) + return null; + return new ResultsImpl(ctx); + } + finally + { + // We have to reset the show-rows setting, since we don't know what's going to be done with this + // queryview after the call to 'getResults'. It's possible it could still be rendered to the client, + // as happens with study datasets. + getSettings().setShowRows(prevShowRows); + } + } + + + @Nullable + public ResultSet getResultSet() throws SQLException, IOException + { + Results r = getResults(); + return r == null ? null : r.getResultSet(); + } + + + public List getExportColumns(List list) + { + List ret = new ArrayList<>(list); + ret.removeIf(next -> next instanceof DetailsColumn || next instanceof UpdateColumn); + return ret; + } + + public final ExcelWriter getExcelWriter(@NotNull ExcelExportConfig config) throws IOException + { + // Call the appropriate overridden method + ExcelWriter ew = getExcelWriter(config.getDocType(), null); + return configureExcelWriter(ew, config); + } + + public ExcelWriter getExcelWriter(ExcelWriter.ExcelDocumentType docType, @Nullable Map renameColumnMap) throws IOException + { + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + RenderContext rc = configureForExcelExport(docType, view, rgn); + + ExcelWriter ew = new ExcelWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), docType, renameColumnMap); + + ew.setFilenamePrefix(getSettings().getQueryName()); + ew.setAutoSize(true); + return ew; + } + + /** + * Sets configuration settings for the provided ExcelWriter according the provided config and this QueryView + * @param excelWriter to configure (CALLER TO CLOSE) + * @param config additional properties to set on the writer + */ + public ExcelWriter configureExcelWriter(ExcelWriter excelWriter, ExcelExportConfig config) + { + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + RenderContext rc = configureForExcelExport(excelWriter.getDocumentType(), view, rgn); + rgn.prepareDisplayColumns(view.getViewContext().getContainer()); + rgn.setAllowAsync(false); + excelWriter.setDisplayColumns(getExportColumns(rgn.getDisplayColumns())); + excelWriter.setResultsFactory(()->rgn.getResults(rc)); + excelWriter.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); + excelWriter.setRenameColumnMap(config.getRenamedColumns()); + excelWriter.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); + excelWriter.setAutoSize(true); + + return excelWriter; + } + + protected ExcelWriter getExcelTemplateWriter(@NotNull ExcelExportConfig config) + { + // The template should be based on the actual columns in the table, not the user's default view, + // which may be hiding columns or showing values joined through lookups + + //NOTE: if the the user passed a viewName param on the URL, we will use these columns + //with the caveat that we will skip and non-user editable columns or those that do + //map to fields in this table (ie. lookups). we will also append any missing + //required columns. + + //TODO: the latter might be problematic if the value of required column is set + //in a validation script. however, the dev could always set it to userEditable=false or nullable=true + List fieldKeys = new ArrayList<>(20); + TableInfo t = createTable(); + + if (!config.getRespectView()) + { + for (ColumnInfo columnInfo : t.getColumns()) + { + FieldKey fieldKey = columnInfo.getFieldKey(); + // Issue 43760: "isUserEditable" does not mean what you think it means. UniqueIdFields must be marked as "UserEditable" + // in order to show up in a details view, but then that makes them show up in the export, where they shouldn't. Booo. + if (config.getIncludeColumns().contains(fieldKey) || (columnInfo.isUserEditable() && !columnInfo.isUniqueIdField())) + { + fieldKeys.add(fieldKey); + } + } + + // Add remaining includeCols to the end + for (FieldKey includeCol : config.getIncludeColumns()) + { + if (!fieldKeys.contains(includeCol)) + fieldKeys.add(includeCol); + } + + } + else + { + // get list of required columns so we can verify presence + Set requiredCols = new HashSet<>(config.getIncludeColumns()); + for (ColumnInfo c : t.getColumns()) + { + if (c.inferIsShownInInsertView()) + requiredCols.add(c.getFieldKey()); + } + + + for (FieldKey key : getCustomView().getColumns()) + { + if (key.getParent() != null) + continue; + + if (requiredCols.contains(key)) + { + fieldKeys.add(key); + requiredCols.remove(key); + continue; + } + + Map cols = QueryService.get().getColumns(t, Collections.singleton(key)); + ColumnInfo col = cols.get(key); + if (col != null && col.isUserEditable()) + { + fieldKeys.add(key); + requiredCols.remove(key); + } + } + + // Add any remaining required columns to the end + fieldKeys.addAll(requiredCols); + } + + List displayColumns = getExcelTemplateDisplayColumns(fieldKeys); + return new ExcelWriter(()->null, displayColumns, config.getDocType(), config.getRenamedColumns()); + } + + protected List getExcelTemplateDisplayColumns(List fieldKeys) + { + // Force the view to use our special list + getSettings().setFieldKeys(fieldKeys); + + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setAllowAsync(false); + rgn.setShowPagination(false); + + // Add explicitly requested columns, even if they don't actually exist on the table. + // They may be magic columns supported on the import side, e.g. "MaterialsInputs/Foo" for SampleTypes. + List displayColumns = rgn.getDisplayColumns(); + Set displayColumnFieldKeys = displayColumns.stream() + .map(DisplayColumn::getColumnInfo) + .filter(Objects::nonNull) + .map(ColumnInfo::getFieldKey) + .collect(Collectors.toSet()); + + for (FieldKey fieldKey : fieldKeys) + { + if (!displayColumnFieldKeys.contains(fieldKey)) + { + DisplayColumn dc = new SimpleDisplayColumn(); + dc.setName(fieldKey.getName()); + displayColumns.add(dc); + } + } + + displayColumns = getExportColumns(displayColumns); + + // Need to remove special MV columns + displayColumns.removeIf(col -> col.getColumnInfo() instanceof RawValueColumn); + + return displayColumns; + } + + protected RenderContext configureForExcelExport(ExcelWriter.ExcelDocumentType docType, DataView view, DataRegion rgn) + { + if (getSettings().getShowRows() == ShowRows.ALL) + { + // Limit the rows returned based on the document type. + // The maxRows setting isn't used unless showRows is PAGINATED. + getSettings().setShowRows(ShowRows.PAGINATED); + getSettings().setMaxRows(docType.getMaxRows()); + } + getSettings().setOffset(Table.NO_OFFSET); + rgn.prepareDisplayColumns(view.getViewContext().getContainer()); // Prep the display columns to translate generic date/time formats, see #21094 + rgn.setAllowAsync(false); + RenderContext rc = view.getRenderContext(); + // Cache resultset only for SAS/SHARE data sources. See #12966 (which removed caching) and #13638 (which added it back for SAS) + boolean sas = "SAS".equals(rgn.getTable().getSqlDialect().getProductName()); + rc.setCache(sas); + return rc; + } + + public static class ExcelExportConfig + { + private HttpServletResponse response; + private ColumnHeaderType headerType; + private Workbook workbook = null; + private ExcelWriter.ExcelDocumentType docType = ExcelWriter.ExcelDocumentType.xlsx; + private Map renamedColumns = new HashMap<>(); + private boolean templateOnly = false; + private boolean insertColumnsOnly = false; + private boolean respectView = false; + private List includeColumns = Collections.emptyList(); + private List excludeColumns = Collections.emptyList(); + private String prefix = null; + + public ExcelExportConfig(HttpServletResponse response, ColumnHeaderType headerType) + { + this.response = response; + this.headerType = headerType; + } + + public ExcelExportConfig setPrefix(String prefix) + { + this.prefix = prefix; + return this; + } + + public String getPrefix() + { + return this.prefix; + } + + public ExcelExportConfig setExcludeColumns(List excludeColumns) + { + this.excludeColumns = excludeColumns; + return this; + } + + public List getExcludeColumns() + { + return this.excludeColumns; + } + + public ExcelExportConfig setIncludeColumns(List includeColumns) + { + this.includeColumns = includeColumns; + return this; + } + + public List getIncludeColumns() + { + return this.includeColumns; + } + + public ExcelExportConfig setRespectView(boolean respectView) + { + this.respectView = respectView; + return this; + } + + public boolean getRespectView() + { + return this.respectView; + } + + public ExcelExportConfig setInsertColumnsOnly(boolean insertColumnsOnly) + { + this.insertColumnsOnly = insertColumnsOnly; + return this; + } + + public boolean getInsertColumnsOnly() + { + return this.insertColumnsOnly; + } + + public ExcelExportConfig setHeaderType(ColumnHeaderType headerType) + { + this.headerType = headerType; + return this; + } + + public ColumnHeaderType getHeaderType() + { + return this.headerType; + } + + public ExcelExportConfig setTemplateOnly(boolean templateOnly) + { + this.templateOnly = templateOnly; + return this; + } + + public boolean getTemplateOnly() + { + return this.templateOnly; + } + + public ExcelExportConfig setRenamedColumns(Map renamedColumns) + { + this.renamedColumns = renamedColumns; + return this; + } + + public Map getRenamedColumns() + { + return this.renamedColumns; + } + + public ExcelExportConfig setDocType(ExcelWriter.ExcelDocumentType docType) + { + this.docType = docType; + return this; + } + + public ExcelWriter.ExcelDocumentType getDocType() + { + return this.docType; + } + + public ExcelExportConfig setResponse(HttpServletResponse response) + { + this.response = response; + return this; + } + + public @NotNull HttpServletResponse getResponse() + { + return this.response; + } + public ExcelExportConfig setWorkbook(Workbook workbook) + { + this.workbook = workbook; + return this; + } + + public @Nullable Workbook getWorkbook() + { + return this.workbook; + } + } + + public void exportToExcel(HttpServletResponse response) throws IOException + { + exportToExcel(new ExcelExportConfig(response, getColumnHeaderType())); + } + + public void exportToExcel(HttpServletResponse response, Workbook workbook) throws IOException + { + exportToExcel(new ExcelExportConfig(response, getColumnHeaderType()).setWorkbook(workbook)); + } + + public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType) throws IOException + { + exportToExcel(new ExcelExportConfig(response, headerType).setDocType(docType)); + } + + public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType, @NotNull Map renameColumn) throws IOException + { + exportToExcel( + new ExcelExportConfig(response, headerType) + .setDocType(docType) + .setRenamedColumns(renameColumn) + ); + } + + public void exportToExcel(ExcelExportConfig config) throws IOException + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + ExcelWriter ew = config.getTemplateOnly() ? getExcelTemplateWriter(config) : getExcelWriter(config); + ew.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); + ew.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); + if (config.getPrefix() != null) + ew.setFilenamePrefix(config.getPrefix()); + ew.setAutoSize(true); + ew.renderWorkbook(config.getResponse()); + + if (!config.getTemplateOnly()) + logAuditEvent("Exported to Excel", ew.getDataRowCount()); + } + } + + @Nullable + public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, @Nullable Map metadata, + @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + return exportToExcelFile(docType, getColumnHeaderType(), metadata, rowsOut, includeTimestamp); + } + + @Nullable + public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, ColumnHeaderType headerType, @Nullable Map metadata, + @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + try (OutputStream stream = new BufferedOutputStream(byteStream)) + { + ExcelWriter ew = getExcelWriter(docType, null); + ew.setCaptionType(headerType); + ew.setShowInsertableColumnsOnly(false, null); + ew.setMetadata(metadata); + ew.renderWorkbook(stream); + String extension = docType.name(); + String filename = includeTimestamp ? + FileUtil.makeFileNameWithTimestamp(ew.getFilenamePrefix(), extension) : + ew.getFilenamePrefix() + "." + extension; + ByteArrayAttachmentFile byteArrayAttachmentFile = + new ByteArrayAttachmentFile(filename, byteStream.toByteArray(), docType.getMimeType()); + + if (null != rowsOut) + rowsOut.add(ew.getDataRowCount()); + logAuditEvent("Exported to Excel file", ew.getDataRowCount()); + return byteArrayAttachmentFile; + } + } + + return null; + } + + public void exportToTsv(HttpServletResponse response) throws IOException + { + exportToTsv(response, TSVWriter.DELIM.TAB, TSVWriter.QUOTE.DOUBLE, getColumnHeaderType()); + } + + public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType) throws IOException + { + exportToTsv(response, delim, quote, headerType, Collections.emptyMap()); + } + + public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException + { + _exportView = true; + TableInfo table = getTable(); + + if (table != null) + { + int rowCount = doExport(response, delim, quote, headerType, renameColumnMap); + logAuditEvent("Exported to TSV", rowCount); + } + } + + private int doExport(HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException + { + try (TSVGridWriter tsv = renameColumnMap.isEmpty() ? getTsvWriter(headerType) : getTsvWriter(headerType, renameColumnMap)) + { + tsv.setDelimiterCharacter(delim); + tsv.setQuoteCharacter(quote); + tsv.write(response); + return tsv.getDataRowCount(); + } + } + + @Nullable + public ByteArrayAttachmentFile exportToTsvFile(final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, + @Nullable List commentLines, @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + StringBuilder tsvBuilder = new StringBuilder(); + + try (TSVGridWriter tsvWriter = getTsvWriter(headerType)) + { + tsvWriter.setDelimiterCharacter(delim); + tsvWriter.setQuoteCharacter(quote); + if (null != commentLines) + tsvWriter.setFileHeader(commentLines); + tsvWriter.write(tsvBuilder); + String extension = delim.extension; + String filename = includeTimestamp ? + FileUtil.makeFileNameWithTimestamp(tsvWriter.getFilenamePrefix(), extension) : + tsvWriter.getFilenamePrefix() + "." + extension; + String contentType = delim.contentType; + ByteArrayAttachmentFile byteArrayAttachmentFile = new ByteArrayAttachmentFile(filename, tsvBuilder.toString().getBytes(StringUtilsLabKey.DEFAULT_CHARSET), contentType); + + if (null != rowsOut) + rowsOut.add(tsvWriter.getDataRowCount()); + logAuditEvent("Exported to TSV file", tsvWriter.getDataRowCount()); + return byteArrayAttachmentFile; + } + } + + return null; + } + + public void exportToApiResponse(ApiQueryResponse response) + { + TableInfo table = getTable(); + if (table != null) + { + _apiResponseView = true; + setShowDetailsColumn(response.isIncludeDetailsColumn()); + setShowUpdateColumn(response.isIncludeUpdateColumn()); + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setShowPaginationCount(!response.isMetaDataOnly()); + + //force the pk column(s) into the default list of columns + List pkCols = table.getPkColumns(); + for (ColumnInfo pkCol : pkCols) + { + if (null == rgn.getDisplayColumn(pkCol.getName())) + rgn.addColumn(pkCol); + } + + RenderContext ctx = view.getRenderContext(); + rgn.setAllowAsync(false); + rgn.prepareDisplayColumns(ctx.getContainer()); + List displayColumns; + if (response.isIncludeDetailsColumn() || response.isIncludeUpdateColumn()) + displayColumns = rgn.getDisplayColumns(); + else + displayColumns = getExportColumns(rgn.getDisplayColumns()); + response.initialize(ctx, rgn, table, displayColumns); + } + else + { + //table was null--try to get parse errors + List errors = getParseErrors(); + if (null != errors && !errors.isEmpty()) + throw errors.get(0); + } + } + + public void exportToExcelWebQuery(HttpServletResponse response) throws Exception + { + TableInfo table = getTable(); + if (null == table) + return; + + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + // Backwards compatibility for export URLs that don't specify a showRows value, see issue 24523 + if (getViewContext().getRequest().getParameter(getSettings().getDataRegionName() + ".showRows") == null) + { + getSettings().setShowRows(ShowRows.ALL); + } + + // We're not sure if we're dealing with a version of Excel that can handle more than 65535 rows. + // Assume that it can, and rely on the fact that Excel throws out rows if there are more than it can handle + RenderContext ctx = configureForExcelExport(ExcelWriter.ExcelDocumentType.xlsx, view, rgn); + + Results results = rgn.getResults(ctx); + + // Bug 5610 & 6179. Excel web queries don't work over SSL if caching is disabled, + // so we need to allow caching so that Excel can read from IE on Windows. + // Set the headers to allow the client to cache, but not proxies + ResponseHelper.setPrivate(response); + + HtmlExportWriter writer = new HtmlExportWriter(); + writer.write(results, getExportColumns(rgn.getDisplayColumns()), response, ctx, true); + + logAuditEvent("Exported to Excel Web Query data", writer.getDataRowCount()); + } + + /** + * Mark all rows in the query view as selected in the user's session. + */ + public int selectAll() throws IOException + { + if (StringUtils.isEmpty(getSelectionKey())) + throw new IllegalStateException(); + + TableInfo table = getTable(); + if (table == null) + throw new IllegalStateException(); + + return DataRegionSelection.setSelectionForAll(this, this.getSelectionKey(), true); + } + + public void logAuditEvent(String comment, int dataRowCount) + { + QueryService.get().addAuditEvent(this, comment, dataRowCount); + } + + public CustomView getCustomView() + { + return _customView; + } + + public void setCustomView(CustomView customView) + { + _customView = customView; + } + + public void setCustomView(String viewName) + { + _settings.setViewName(viewName); + _customView = _settings.getCustomView(getViewContext(), getQueryDef()); + } + + protected TableInfo createTable() + { + QueryDefinition qdef = getQueryDef(); + if (null == qdef) + return null; + qdef.setContainerFilter(getContainerFilter()); + return qdef.getTable(_schema, _parseErrors, true); + } + + final public TableInfo getTable() + { + // We'll have parseErrors if we already tried and failed to create the table + if (_table != null || !_parseErrors.isEmpty()) + return _table; + _table = createTable(); + + /* TODO ContainerFilter check that this is correct for hasUnionTable() */ + if (_table instanceof ContainerFilterable && _table.supportsContainerFilter()) + { + ContainerFilter filter = getContainerFilter(); + if (filter != null) + { + // If table has a Union version, apply the filter to the Union + UserSchema userSchema = _table.getUserSchema(); + if (ContainerFilter.Type.Current != filter.getType() && null != userSchema && _table.hasUnionTable()) + { + Set containers = new HashSet<>(); + if (ContainerFilter.Type.AllFolders != filter.getType()) + { + Collection containerIds = filter.getIds(); + if (null != containerIds) + { + for (GUID id : containerIds) + containers.add(ContainerManager.getForId(id)); + } + } + else + { + containers = ContainerManager.getAllChildren(ContainerManager.getRoot()); + } + + if (!containers.isEmpty()) + _table = userSchema.getUnionTable(_table, containers); + } + } + } + + return _table; + } + + // This can be used to override the container filter that would otherwise be provided by the QuerySettings + ContainerFilter _overrideContainerFilter = null; + + public void setContainerFilter(ContainerFilter cf) + { + _overrideContainerFilter = cf; + } + + @Nullable + protected ContainerFilter getContainerFilter() + { + if (null != _overrideContainerFilter) + return _overrideContainerFilter; + + String filterName = _settings.getContainerFilterName(); + + if (filterName == null && _customView != null) + filterName = _customView.getContainerFilterName(); + + if (filterName != null) + return ContainerFilter.getContainerFilterByName(filterName, getContainer(), getUser()); + + return null; + } + + private boolean isShowExperimentalGenericDetailsURL() + { + return AppProps.getInstance().isOptionalFeatureEnabled(EXPERIMENTAL_GENERIC_DETAILS_URL); + } + + + List _queryDefDisplayColumns = null; + + public List getDisplayColumns() + { + TableInfo table = getTable(); + if (table == null) + return Collections.emptyList(); + + List ret = new ArrayList<>(); + addDetailsAndUpdateColumns(ret, table); + + if (null == _queryDefDisplayColumns) + _queryDefDisplayColumns = getQueryDef().getDisplayColumns(_customView, table); + ret.addAll(_queryDefDisplayColumns); + + if (_linkTarget != null) + { + for (DisplayColumn displayColumn : ret) + { + displayColumn.setLinkTarget(_linkTarget); + } + } + return ret; + } + + protected void addDetailsAndUpdateColumns(List ret, TableInfo table) + { + // Print view and export view don't need details and update columns, + // but the selectRows API can turn them on to include the URLs in the response format. + if (isPrintView() || isExportView()) + return; + + if (_showDetailsColumn && (null != _detailsURL || table.hasDetailsURL() || isShowExperimentalGenericDetailsURL())) + { + StringExpression urlDetails = urlExpr(QueryAction.detailsQueryRow); + + if (urlDetails != null && urlDetails != AbstractTableInfo.LINK_DISABLER) + { + // We'll decide at render time if we have enough columns in the results to make the DetailsColumn visible + DisplayColumn dc = createDetailsColumn(urlDetails, table); + if (null != dc) + ret.add(dc); + } + } + + if (_showUpdateColumn && (canUpdate() || allowQueryTableUpdateURLOverride())) + { + StringExpression urlUpdate = urlExpr(QueryAction.updateQueryRow); + if (urlUpdate != null) + { + DisplayColumn dc = createUpdateColumn(urlUpdate, table); + if (null != dc) + ret.add(0, dc); + } + } + } + + /** + * The intent of this method is to ensure that the update/details URL inherit the + * ContainerContext from the table unless explicitly set. This is relevant because QWPs can + * supply custom update/detailsURLs as a string, which has no ContainerContext. Most TableInfos + * always set the ContainerContext on the details/update URLs to ContainerContext.FieldKeyContext, + * which delegates the container to row-level (usually based on a container column). + */ + private void ensureUrlContainerContext(StringExpression se, TableInfo table) + { + if (se instanceof DetailsURL du) + { + if (!du.hasContainerContext()) + { + du.setContainerContext(table.getContainerContext()); + } + } + } + + @Nullable + protected DisplayColumn createDetailsColumn(StringExpression urlDetails, TableInfo table) + { + ensureUrlContainerContext(urlDetails, table); + + return new DetailsColumn(urlDetails, table); + } + + protected DisplayColumn createUpdateColumn(StringExpression urlUpdate, TableInfo table) + { + ensureUrlContainerContext(urlUpdate, table); + + return new UpdateColumn.Impl(urlUpdate); + } + + public QueryDefinition getQueryDef() + { + return _queryDef; + } + + public List getParseErrors() + { + return _parseErrors; + } + + public NavTrailConfig getNavTrailConfig() + { + NavTrailConfig ret = new NavTrailConfig(getRootContext()); + ret.setExtraChildren(new NavTree(getSchema().getSchemaName() + " queries", getSchema().urlFor(QueryAction.begin))); + return ret; + } + + public void setShowExportButtons(boolean showExportButtons) + { + _showExportButtons = showExportButtons; + } + + public boolean showExportButtons() + { + return _showExportButtons; + } + + public boolean showRStudioButton() + { + return _showRStudioButton; + } + + /** Currently requires showExportButtons(), or button will not be enabled */ + public void setShowRStudioButton(boolean showRStudioButton) + { + _showRStudioButton = showRStudioButton; + } + + public void setShowDetailsColumn(boolean showDetailsColumn) + { + _showDetailsColumn = showDetailsColumn; + } + + public void setShowUpdateColumn(boolean showUpdateColumn) + { + _showUpdateColumn = showUpdateColumn; + } + + public void setUpdateURL(String updateURL) + { + _updateURL = null==updateURL ? null : DetailsURL.fromString(updateURL); + } + + public void setUpdateURL(DetailsURL updateURL) + { + _updateURL = updateURL; + } + + public void setDetailsURL(String detailsURL) + { + _detailsURL = null==detailsURL ? null : DetailsURL.fromString(detailsURL); + } + + public void setDetailsURL(DetailsURL detailsURL) + { + _detailsURL = detailsURL; + } + + public void setDeleteURL(String deleteURL) + { + _deleteURL = deleteURL; + } + + public void setInsertURL(String insertURL) + { + _insertURL = insertURL; + } + + public void setImportURL(String importURL) + { + _importURL = importURL; + } + + public void setPrintView(boolean b) + { + _printView = b; + } + + public boolean isPrintView() + { + return _printView; + } + + public boolean isExportView() + { + return _exportView; + } + + public boolean isApiResponseView() + { + return _apiResponseView; + } + + public void setApiResponseView(boolean apiResponseView) + { + _apiResponseView = apiResponseView; + } + + public boolean isUseQueryViewActionExportURLs() + { + return _useQueryViewActionExportURLs; + } + + public void setUseQueryViewActionExportURLs(boolean useQueryViewActionExportURLs) + { + _useQueryViewActionExportURLs = useQueryViewActionExportURLs; + } + + public boolean isAllowExportExternalQuery() + { + return _allowExportExternalQuery; + } + + public void setAllowExportExternalQuery(boolean allowExportExternalQuery) + { + _allowExportExternalQuery = allowExportExternalQuery; + } + + public boolean isShadeAlternatingRows() + { + return _shadeAlternatingRows; + } + + public void setShadeAlternatingRows(boolean shadeAlternatingRows) + { + _shadeAlternatingRows = shadeAlternatingRows; + } + + public boolean isShowFilterDescription() + { + return _showFilterDescription; + } + + public void setShowFilterDescription(boolean showFilterDescription) + { + _showFilterDescription = showFilterDescription; + } + + public boolean isShowBorders() + { + return _showBorders; + } + + public void setShowBorders(boolean showBorders) + { + _showBorders = showBorders; + } + + public boolean isShowSurroundingBorder() + { + return _showSurroundingBorder; + } + + public void setShowSurroundingBorder(boolean showSurroundingBorder) + { + _showSurroundingBorder = showSurroundingBorder; + } + + public boolean isShowPagination() + { + return _showPagination; + } + + public void setShowPagination(boolean showPagination) + { + _showPagination = showPagination; + } + + public boolean isShowPaginationCount() + { + return _showPaginationCount; + } + + public void setShowPaginationCount(boolean showPaginationCount) + { + _showPaginationCount = showPaginationCount; + } + + /** + * controls display of the reports and charts button + */ + public boolean isShowReports() + { + // buttons can be hidden either through query settings or method overriding + return _showReports && getSettings().isShowReports(); + } + + public void setShowReports(boolean showReports) + { + _showReports = showReports; + } + + public boolean isShowConfiguredButtons() + { + return _showConfiguredButtons; + } + + public void setShowConfiguredButtons(boolean showConfiguredButtons) + { + _showConfiguredButtons = showConfiguredButtons; + } + + @NotNull + public Set getAllowableContainerFilterTypes() + { + return _allowableContainerFilterTypes; + } + + public void setAllowableContainerFilterTypes(@NotNull Collection allowableContainerFilterTypes) + { + _allowableContainerFilterTypes = Collections.unmodifiableSet(new LinkedHashSet<>(allowableContainerFilterTypes)); + } + + public void setAllowableContainerFilterTypes(ContainerFilter.Type... allowableContainerFilterTypes) + { + setAllowableContainerFilterTypes(Arrays.asList(allowableContainerFilterTypes)); + } + + public void disableContainerFilterSelection() + { + _allowableContainerFilterTypes = Collections.emptySet(); + } + + public List getAnalyticsProviders() + { + return getSettings().getAnalyticsProviders(); + } + + @NotNull + @Override + public LinkedHashSet getClientDependencies() + { + LinkedHashSet resources = new LinkedHashSet<>(); + resources.addAll(super.getClientDependencies()); + + ButtonBarConfig cfg = _buttonBarConfig; + if (cfg == null) + { + TableInfo ti = _table; + if (ti == null) + { + List errors = new ArrayList<>(); + QueryDefinition queryDef = getQueryDef(); + if (queryDef != null) + { + if (null != getContainerFilter()) + queryDef.setContainerFilter(getContainerFilter()); + ti = queryDef.getTable(getSchema(), errors, true, false); + } + } + + if (ti != null) + cfg = ti.getButtonBarConfig(); + } + + if (cfg != null && cfg.getScriptIncludes() != null) + { + for (String script : cfg.getScriptIncludes()) + { + resources.add(ClientDependency.fromPath(script)); + } + } + + List displayColumns = getDisplayColumns(); + + if (null != displayColumns) + { + for (DisplayColumn dc : displayColumns) + { + resources.addAll(dc.getClientDependencies()); + } + } + + return resources; + } + + public void setMessageSupplier(DataRegion.MessageSupplier messageSupplier) + { + _messageSupplier = messageSupplier; + } +} diff --git a/list/src/org/labkey/list/controllers/ListController.java b/list/src/org/labkey/list/controllers/ListController.java index 44cda4aa4d5..0e1633903d3 100644 --- a/list/src/org/labkey/list/controllers/ListController.java +++ b/list/src/org/labkey/list/controllers/ListController.java @@ -1,1164 +1,1256 @@ -/* - * Copyright (c) 2013-2023 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.controllers; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONObject; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.ExportException; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.FolderArchiveDataTypes; -import org.labkey.api.admin.FolderExportContext; -import org.labkey.api.admin.StaticLoggerGetter; -import org.labkey.api.attachments.AttachmentForm; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.audit.view.AuditChangesView; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.defaults.ClearDefaultValuesAction; -import org.labkey.api.defaults.DomainIdForm; -import org.labkey.api.defaults.SetDefaultValuesAction; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.list.ListUrls; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.lists.permissions.ManagePicklistsPermission; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.UserSchema; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpPostRedirectView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.ZipFile; -import org.labkey.list.model.ListAuditProvider; -import org.labkey.list.model.ListDef; -import org.labkey.list.model.ListDefinitionImpl; -import org.labkey.list.model.ListDomainKindProperties; -import org.labkey.list.model.ListManager; -import org.labkey.list.model.ListManagerSchema; -import org.labkey.list.model.ListWriter; -import org.labkey.list.view.ListDefinitionForm; -import org.labkey.list.view.ListItemAttachmentParent; -import org.labkey.list.view.ListQueryForm; -import org.labkey.list.view.ListQueryView; -import org.springframework.beans.PropertyValue; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.ModelAndView; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; - -public class ListController extends SpringActionController -{ - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(ListController.class, ClearDefaultValuesAction.class); - - public ListController() - { - setActionResolver(_actionResolver); - } - - - private void addRootNavTrail(NavTree root) - { - addRootNavTrail(root, getContainer(), getUser()); - } - - public static class ListUrlsImpl implements ListUrls - { - @Override - public ActionURL getManageListsURL(Container c) - { - return new ActionURL(ListController.BeginAction.class, c); - } - - @Override - public ActionURL getCreateListURL(Container c) - { - return new ActionURL(EditListDefinitionAction.class, c); - } - } - - - public static void addRootNavTrail(NavTree root, Container c, User user) - { - if (c.hasOneOf(user, DesignListPermission.class, PlatformDeveloperPermission.class)) - { - root.addChild("Lists", getBeginURL(c)); - } - } - - - private void addListNavTrail(NavTree root, ListDefinition list, @Nullable String title) - { - addRootNavTrail(root); - root.addChild(list.getName(), list.urlShowData()); - - if (null != title) - root.addChild(title); - } - - - public static ActionURL getBeginURL(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public PageConfig defaultPageConfig() - { - PageConfig config = super.defaultPageConfig(); - return config.setHelpTopic("lists"); - } - - @RequiresPermission(ReadPermission.class) - public static class BeginAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm queryForm, BindException errors) - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), ListManagerSchema.SCHEMA_NAME); - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, ListManagerSchema.LIST_MANAGER); - - // users should see all lists without a category and public picklists and any lists they created. - SimpleFilter filter = new SimpleFilter(); - - SQLFragment sql = new SQLFragment("Category IS NULL OR Category = ") - .appendValue(ListDefinition.Category.PublicPicklist) - .append(" OR CreatedBy = ").appendValue(getUser().getUserId()); - filter.addWhereClause(sql, FieldKey.fromParts("Category"), FieldKey.fromParts("CreatedBy")); - settings.setBaseFilter(filter); - - if (null == StringUtils.trimToNull(settings.getContainerFilterName())) - settings.setContainerFilterName(ContainerFilter.Type.CurrentPlusProjectAndShared.name()); - - return schema.createView(getViewContext(), settings, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Available Lists"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowListDefinitionAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(ListDefinitionForm listDefinitionForm) - { - if (listDefinitionForm.getListId() == null) - { - throw new NotFoundException(); - } - return new ActionURL(EditListDefinitionAction.class, getContainer()).addParameter("listId", listDefinitionForm.getListId().intValue()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetListPropertiesAction extends ReadOnlyApiAction - { - @Override - public Object execute(ListDefinitionForm form, BindException errors) throws Exception - { - ListDomainKindProperties properties = ListManager.get().getListDomainKindProperties(getContainer(), form.getListId()); - if (properties != null) - return properties; - else - throw new NotFoundException("List does not exist in this container for listId " + form.getListId() + "."); - } - } - - @RequiresPermission(DesignListPermission.class) - public class EditListDefinitionAction extends SimpleViewAction - { - private ListDefinition _list; - String listDesignerHeader = "List Designer"; - - @Override - public ModelAndView getView(ListDefinitionForm form, BindException errors) - { - _list = null; - boolean createList = (null == form.getListId() || 0 == form.getListId()) && form.getName() == null; - if (!createList) - _list = form.getList(); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("listDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - if (null == _list) - { - root.addChild(listDesignerHeader); - } - else - { - addListNavTrail(root, _list, listDesignerHeader); - } - } - } - - @RequiresAnyOf({DesignListPermission.class, ManagePicklistsPermission.class}) - public static class DeleteListDefinitionAction extends ConfirmAction - { - private boolean canDelete(Container listContainer, int listId) - { - ListDef listDef = ListManager.get().getList(listContainer, listId); - ListDefinitionImpl list = ListDefinitionImpl.of(listDef); - - if (list == null) - return false; - - boolean isPicklist = listDef.getCategory() != null; - if (isPicklist) - { - boolean isOwnPicklist = listDef.getCreatedBy() == getUser().getUserId(); - return isOwnPicklist || (listDef.getCategory() == ListDefinition.Category.PublicPicklist && list.getContainer().hasPermission(getUser(), AdminPermission.class)); - } - - return list.getContainer().hasPermission(getUser(), DesignListPermission.class); - } - - @Override - public String getConfirmText() - { - return "Confirm Delete"; - } - - @Override - public void validateCommand(ListDeletionForm form, Errors errors) - { - if (form.getListId() != null) - { - if (canDelete(getContainer(), form.getListId())) - form.getListContainerMap().add(Pair.of(form.getListId(), getContainer())); - else - errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", form.getListId(), getContainer().getName())); - } - else if (form.getName() != null) - { - var list = form.getList(); - if (canDelete(list.getContainer(), list.getListId())) - form.getListContainerMap().add(Pair.of(list.getListId(), getContainer())); - else - errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", list.getListId(), getContainer().getName())); - } - else - { - List errorMessages = new ArrayList<>(); - Collection listIDs; - if (form.getListIds() != null) - listIDs = form.getListIds(); - else - listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); - - for (Pair pair : getListIdContainerPairs(listIDs, getContainer(), errorMessages)) - { - var listId = pair.first; - var listContainer = pair.second; - - if (canDelete(listContainer, listId)) - form.getListContainerMap().add(pair); - else - errorMessages.add(String.format("You do not have permission to delete list %s in container %s", listId, listContainer.getName())); - } - - if (!errorMessages.isEmpty()) - errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); - } - - if (form.getListContainerMap().isEmpty()) - errors.reject(ERROR_MSG, "You must specify a list or lists to delete."); - } - - @Override - public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Confirm Deletion"); - return new JspView<>("/org/labkey/list/view/deleteListDefinition.jsp", form, errors); - } - - @Override - public boolean handlePost(ListDeletionForm form, BindException errors) - { - for (Pair pair : form.getListContainerMap()) - { - ListDefinition listDefinition = ListService.get().getList(pair.second, pair.first); - if (null != listDefinition) - { - try - { - listDefinition.delete(getUser()); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, "Error deleting list '" + listDefinition.getName() + "'; another user may have deleted it."); - } - } - } - - return !errors.hasErrors(); - } - - @Override @NotNull - public URLHelper getSuccessURL(ListDeletionForm form) - { - return form.getReturnUrlHelper(getBeginURL(getContainer())); - } - } - - public static class ListDeletionForm extends ListDefinitionForm - { - private List _listIds; - private final List> _listContainerMap = new ArrayList<>(); - - public List getListIds() - { - return _listIds; - } - - public void setListIds(List listIds) - { - _listIds = listIds; - } - - public List> getListContainerMap() - { - return _listContainerMap; - } - } - - - @RequiresPermission(ReadPermission.class) - public class GridAction extends SimpleViewAction - { - private ListDefinition _list; - private String _title; - - @Override - public ModelAndView getView(ListQueryForm form, BindException errors) - { - _list = form.getList(); - if (null == _list) - throw new NotFoundException("List does not exist in this container"); - - if (!_list.isVisible(getUser())) - throw new UnauthorizedException("User is not allowed to see this list."); - - ListQueryView view = new ListQueryView(form, errors); - - TableInfo ti = view.getTable(); - if (ti != null) - { - _title = ti.getTitle(); - } - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, _title); - } - } - - - public abstract static class InsertUpdateAction extends FormViewAction - { - protected abstract ActionURL getActionView(ListDefinition list, BindException errors); - protected abstract Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues); - - @Override - public void validateCommand(ListDefinitionForm form, Errors errors) - { - /* No-op */ - } - - @Override - public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) - { - ListDefinition list = form.getList(); // throws NotFoundException - - ActionURL url = getActionView(list, errors); - Collection> inputs = getInputs(list, url, getPropertyValues().getPropertyValues()); - - if (getViewContext().getRequest().getMethod().equalsIgnoreCase("POST")) - { - getPageConfig().setTemplate(PageConfig.Template.None); - return new HttpPostRedirectView(url.toString(), inputs); - } - - throw new RedirectException(url); - } - - @Override - public boolean handlePost(ListDefinitionForm form, BindException errors) - { - return true; - } - - @Override - public URLHelper getSuccessURL(ListDefinitionForm form) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - /** - * DO NOT USE. This action has been deprecated in 13.2 in favor of the standard query/insertQueryRow action. - * Only here for backwards compatibility to resolve requests and redirect. - */ - @Deprecated - @RequiresPermission(InsertPermission.class) - public class InsertAction extends InsertUpdateAction - { - @Override - protected ActionURL getActionView(ListDefinition list, BindException errors) - { - TableInfo listTable = list.getTable(getUser()); - return listTable.getUserSchema().getQueryDefForTable(listTable.getName()).urlFor(QueryAction.insertQueryRow, getContainer()); - } - - @Override - protected Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues) - { - Collection> inputs = new ArrayList<>(); - - for (PropertyValue value : propertyValues) - { - if (value.getName().equals(ActionURL.Param.returnUrl.toString())) - { - url.addParameter(ActionURL.Param.returnUrl, (String) value.getValue()); - } - else - inputs.add(Pair.of(value.getName(), value.getValue().toString())); - } - - return inputs; - } - } - - public static class ListDetailsForm extends ListDefinitionForm - { - private Object _pk; - - public Object getPk() - { - return _pk; - } - - public void setPk(Object pk) - { - _pk = pk; - } - } - - @RequiresPermission(ReadPermission.class) - public class DetailsAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListDetailsForm form, BindException errors) - { - _list = form.getList(); - TableInfo table = _list.getTable(getUser(), getContainer()); - - if (null == table) - throw new NotFoundException("List does not exist"); - - ListQueryUpdateForm tableForm = new ListQueryUpdateForm(table, getViewContext(), _list, form.getPk(), errors); - DetailsView details = new DetailsView(tableForm); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - ActionButton gridButton; - ActionURL gridUrl = _list.urlShowData(getViewContext().getContainer()); - gridButton = new ActionButton("Show Grid", gridUrl); - - if (table.hasPermission(getUser(), UpdatePermission.class)) - { - ActionURL updateUrl = _list.urlUpdate(getUser(), getContainer(), tableForm.getPkVal(), gridUrl); - ActionButton editButton = new ActionButton("Edit", updateUrl); - bb.add(editButton); - } - - bb.add(gridButton); - details.getDataRegion().setButtonBar(bb); - - VBox view = new VBox(); - ListItem item; - item = _list.getListItem(tableForm.getPkVal(), getUser(), getContainer()); - - if (null == item) - throw new NotFoundException("List item '" + tableForm.getPkVal() + "' does not exist"); - - view.addView(details); - - if (form.isShowHistory()) - { - WebPartView linkView = new HtmlView(LinkBuilder.labkeyLink("hide item history", getViewContext().cloneActionURL().deleteParameter("showHistory")).build()); - linkView.setFrame(WebPartView.FrameType.NONE); - view.addView(linkView); - - UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); - if (schema != null) - { - QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - - SimpleFilter filter = new SimpleFilter(); - filter.addCondition(FieldKey.fromParts(ListAuditProvider.COLUMN_NAME_LIST_ITEM_ENTITY_ID), item.getEntityId()); - - settings.setBaseFilter(filter); - settings.setQueryName(ListManager.LIST_AUDIT_EVENT); - QueryView history = schema.createView(getViewContext(), settings, errors); - - history.setTitle("List Item History:"); - history.setFrame(WebPartView.FrameType.NONE); - view.addView(history); - } - } - else - { - view.addView(new HtmlView(LinkBuilder.labkeyLink("show item history", getViewContext().cloneActionURL().addParameter("showHistory", "1")).build())); - } - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, "View List Item"); - } - } - - - // Override to ensure that pk value type matches column type. This is critical for PostgreSQL 8.3. - public static class ListQueryUpdateForm extends QueryUpdateForm - { - private final ListDefinition _list; - private final Object _pk; - - public ListQueryUpdateForm(TableInfo table, ViewContext ctx, ListDefinition list, @Nullable Object pk, BindException errors) - { - super(table, ctx, errors); - _list = list; - _pk = pk; - } - - @Override - public Object[] getPkVals() - { - if (_pk != null) - { - return new Object[]{_pk}; - } - else - { - Object[] pks = super.getPkVals(); - assert 1 == pks.length; - pks[0] = _list.getKeyType().convertKey(pks[0]); - return pks; - } - } - - public Domain getDomain() - { - return _list != null ? _list.getDomain() : null; - } - } - - - // Users can change the PK of a list item, so we don't want to store PK in discussion source URL (back link - // from announcements to the object). Instead, we tell discussion service to store a URL with ListId and - // EntityId. This action resolves to the current details URL for that item. - @RequiresPermission(ReadPermission.class) - public class ResolveAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(ListDefinitionForm form) - { - ListDefinition list = form.getList(); - ListItem item = list.getListItemForEntityId(getViewContext().getActionURL().getParameter("entityId"), getUser()); // TODO: Use proper form, validate - ActionURL url = getViewContext().cloneActionURL().setAction(DetailsAction.class); // Clone to preserve discussion params - url.deleteParameter("entityId"); - url.addParameter("pk", item.getKey().toString()); - - return url; - } - } - - - @RequiresPermission(InsertPermission.class) - public class UploadListItemsAction extends AbstractQueryImportAction - { - private ListDefinition _list; - private QueryUpdateService.InsertOption _insertOption; - - @Override - protected void initRequest(ListDefinitionForm form) throws ServletException - { - _list = form.getList(); - _insertOption = form.getInsertOption(); - setTarget(_list.getTableForInsert(getUser(), getContainer())); - } - - @Override - public ModelAndView getView(ListDefinitionForm form, BindException errors) throws Exception - { - initRequest(form); - boolean allowImportOptions = _list.getKeyType() != ListDefinition.KeyType.AutoIncrementInteger; - setShowMergeOption(allowImportOptions); - setShowUpdateOption(allowImportOptions); - setSuccessMessageSuffix("imported"); - return getDefaultImportView(form, errors); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - return _list.importListItems(getUser(), getContainer(), dl, errors, null, null, false, getLookupResolutionType(), _insertOption); - } - - @Override - protected void validatePermission(User user, BindException errors) - { - super.validatePermission(user, errors); - if (!_list.getAllowUpload()) - errors.reject(SpringActionController.ERROR_MSG, "This list does not allow uploading data"); - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, "Import Data"); - } - } - - - @RequiresPermission(ReadPermission.class) - public class HistoryAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListQueryForm form, BindException errors) - { - _list = form.getList(); - if (_list != null) - { - UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); - if (schema != null) - { - VBox box = new VBox(); - String domainUri = _list.getDomain().getTypeURI(); - - // list audit events - QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - SimpleFilter eventFilter = new SimpleFilter(); - eventFilter.addCondition(FieldKey.fromParts(ListManager.LISTID_FIELD_NAME), _list.getListId()); - settings.setBaseFilter(eventFilter); - settings.setQueryName(ListManager.LIST_AUDIT_EVENT); - - QueryView view = schema.createView(getViewContext(), settings, errors); - view.setTitle("List Events"); - box.addView(view); - - // domain audit events associated with this list - QuerySettings domainSettings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - - SimpleFilter domainFilter = new SimpleFilter(); - domainFilter.addCondition(FieldKey.fromParts(DomainAuditProvider.COLUMN_NAME_DOMAIN_URI), domainUri); - domainSettings.setBaseFilter(domainFilter); - - domainSettings.setQueryName(DomainAuditProvider.EVENT_TYPE); - QueryView domainView = schema.createView(getViewContext(), domainSettings, errors); - - domainView.setTitle("List Design Changes"); - box.addView(domainView); - - return box; - } - return HtmlView.of("Unable to create the List history view"); - } - else - return HtmlView.of("Unable to find the specified List"); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_list != null) - addListNavTrail(root, _list, _list.getName() + ":History"); - else - root.addChild(":History"); - } - } - - private String getUrlParam(Enum param) - { - String s = getViewContext().getActionURL().getParameter(param); - ReturnUrlForm form = new ReturnUrlForm(); - form.setReturnUrl(s); - return form.getReturnUrl(); - } - - public static class ListItemDetailsForm - { - private Integer _listId; - private String _name; - private Integer _rowId; - - public Integer getListId() - { - return _listId; - } - - public void setListId(Integer listId) - { - _listId = listId; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - } - - @RequiresPermission(ReadPermission.class) - public class ListItemDetailsAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListItemDetailsForm form, BindException errors) - { - String listName = form.getName(); - if (listName != null) - _list = ListService.get().getList(getContainer(), listName, true); - - if (_list == null) - { - Integer listId = form.getListId(); - if (listId != null && listId > 0) - _list = ListService.get().getList(getContainer(), listId); - } - - if (_list == null) - return HtmlView.of("This list is no longer available."); - - String comment = null; - String oldRecord = null; - String newRecord = null; - - Integer eventRowId = form.getRowId(); - if (eventRowId == null || eventRowId <= 0) - return HtmlView.of("Unable to resolve event details. An event \"rowId\" must be specified."); - - ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), ListManager.LIST_AUDIT_EVENT, eventRowId); - - if (event != null) - { - comment = event.getComment(); - oldRecord = event.getOldRecordMap(); - newRecord = event.getNewRecordMap(); - } - - if (!StringUtils.isEmpty(oldRecord) || !StringUtils.isEmpty(newRecord)) - { - Map oldData = ListAuditProvider.decodeFromDataMap(oldRecord); - Map newData = ListAuditProvider.decodeFromDataMap(newRecord); - - String srcUrl = getUrlParam(ActionURL.Param.redirectUrl); - if (srcUrl == null) - srcUrl = getUrlParam(ActionURL.Param.returnUrl); - if (srcUrl == null) - srcUrl = _list.urlFor(ListController.HistoryAction.class, getContainer()).getLocalURIString(); - AuditChangesView view = new AuditChangesView(comment, oldData, newData); - view.setReturnUrl(srcUrl); - - return view; - } - else - return HtmlView.of("No details available for this event."); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_list != null) - addListNavTrail(root, _list, "List Item Details"); - else - root.addChild("List Item Details"); - } - } - - - public static class ListAttachmentForm extends AttachmentForm - { - private int _listId; - - public int getListId() - { - return _listId; - } - - public void setListId(int listId) - { - _listId = listId; - } - } - - - public static ActionURL getDownloadURL(ListDefinition list, String rowEntityId, String name) - { - return new ActionURL(DownloadAction.class, list.getContainer()) - .addParameter("listId", list.getListId()) - .addParameter("entityId", rowEntityId) - .addParameter("name", name); - } - - @RequiresPermission(ReadPermission.class) - public static class DownloadAction extends BaseDownloadAction - { - @Override - public void validate(ListAttachmentForm form, BindException errors) - { - if (!GUID.isGUID(form.getEntityId())) - { - errors.rejectValue("entityId", ERROR_MSG, "entityId is not a GUID: " + form.getEntityId()); - } - } - - @Nullable - @Override - public Pair getAttachment(ListAttachmentForm form) - { - ListDefinitionImpl listDef = (ListDefinitionImpl)ListService.get().getList(getContainer(), form.getListId()); - if (listDef == null) - throw new NotFoundException("List does not exist in this container"); - - if (!listDef.hasListItemForEntityId(form.getEntityId(), getUser())) - throw new NotFoundException("List does not have an item for the entityid"); - - AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), getContainer()); - - return new Pair<>(parent, form.getName()); - } - } - - - @RequiresPermission(DesignListPermission.class) - public static class ExportListArchiveAction extends ExportAction - { - @Override - public void export(ListDefinitionForm form, HttpServletResponse response, BindException errors) throws Exception - { - Container c = getContainer(); - List errorMessages = new ArrayList<>(); - Set selection = DataRegionSelection.getSelected(form.getViewContext(), false); - List> selectedLists = new LinkedList<>(); - Map duplicateNames = new HashMap<>(); - - for (Pair pair : getListIdContainerPairs(selection, c, errorMessages)) - { - String listName = Objects.requireNonNull(ListManager.get().getList(pair.second, pair.first)).getName(); - - //Display simple error to the user when Lists with the same names are selected. - if (duplicateNames.containsKey(listName)) - { - errors.reject(ERROR_MSG, "'" + listName + "' is already selected, please select Lists with unique names to Export."); - throw new ExportException(new SimpleErrorView(errors, true)); - } - else - { - duplicateNames.put(listName, pair.first); - } - // Issue 47289: Export List Archive if the user is an Admin of the folders of the selected Lists, else throw Permission error - if (!pair.second.hasPermission(getUser(), DesignListPermission.class)) - { - errors.reject(ERROR_MSG, String.format("List archive export is only supported for Lists in folders where you are an administrator. Try filtering to select only Lists in the local folder.")); - throw new ExportException(new SimpleErrorView(errors, true)); - } - selectedLists.add(pair); - } - - Set dataTypes = PageFlowUtil.set(FolderArchiveDataTypes.LIST_DESIGN, FolderArchiveDataTypes.LIST_DATA); - FolderExportContext ctx = new FolderExportContext(getUser(), c, dataTypes, "List Export", new StaticLoggerGetter(LogHelper.getLogger(ListController.class, "Export List Archive"))); - ctx.setLists(selectedLists); - ListWriter writer = new ListWriter(); - - // Export to a temporary file first so exceptions are displayed by the standard error page, Issue #44152 - // Same pattern as ExportFolderAction - Path tempDir = FileUtil.getTempDirectory().toPath(); - String filename = FileUtil.makeFileNameWithTimestamp(c.getName(), "lists.zip"); - - try (ZipFile zip = new ZipFile(tempDir, filename)) - { - writer.write(getUser(), zip, ctx); - } - - Path tempZipFile = tempDir.resolve(filename); - - // No exceptions, so stream the resulting zip file to the browser and delete it - try (OutputStream os = ZipFile.getOutputStream(getViewContext().getResponse(), filename)) - { - Files.copy(tempZipFile, os); - } - finally - { - Files.delete(tempZipFile); - } - } - } - - - @RequiresPermission(DesignListPermission.class) - public class ImportListArchiveAction extends FormViewAction - { - @Override - public void validateCommand(ListDefinitionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/list/view/importLists.jsp", null, errors); - } - - @Override - public boolean handlePost(ListDefinitionForm form, BindException errors) throws Exception - { - Map map = getFileMap(); - - if (map.isEmpty()) - { - errors.reject("listImport", "You must select a .list.zip file to import."); - } - else if (map.size() > 1) - { - errors.reject("listImport", "Only one file is allowed."); - } - else - { - MultipartFile file = map.values().iterator().next(); - - if (0 == file.getSize() || StringUtils.isBlank(file.getOriginalFilename())) - { - errors.reject("listImport", "You must select a .list.zip file to import."); - } - else - { - ListService.get().importListArchive(file.getInputStream(), errors, getContainer(), getUser()); - } - } - - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(ListDefinitionForm form) - { - return form.getReturnActionURL( getBeginURL(getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Import List Archive"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseListsAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - response.put("lists", getJSONLists(ListService.get().getLists(getContainer(), getUser(), true))); - response.put("success", true); - - return response; - } - - private List getJSONLists(Map lists){ - List listsJSON = new ArrayList<>(); - for(ListDefinition def : new TreeSet<>(lists.values())){ - JSONObject listObj = new JSONObject(); - listObj.put("name", def.getName()); - listObj.put("id", def.getListId()); - listObj.put("description", def.getDescription()); - listsJSON.add(listObj); - } - return listsJSON; - } - } - - @RequiresPermission(DesignListPermission.class) - public static class SetDefaultValuesListAction extends SetDefaultValuesAction - { - } - - /** - * Utility method to parse out Pair from a Collection where the strings are encoded - * pairs of listIds and container entityIds separated (e.g. "12,ff72c81e-ce2d-103a-b3ce-e8f660509016"). - */ - private static List> getListIdContainerPairs( - Collection listIdContainers, - Container currentContainer, - Collection errors) - { - List> pairs = new ArrayList<>(); - - for (String s : listIdContainers) - { - String[] parts = s.split(","); - Container c; - if (parts.length > 1) - c = ContainerManager.getForId(parts[1]); - else - c = currentContainer; - if (c == null) - { - errors.add(String.format("Container not found for %s", s)); - continue; - } - - try - { - int listId = Integer.parseInt(parts[0]); - pairs.add(Pair.of(listId, c)); - } - catch (NumberFormatException badListId) - { - errors.add(String.format("Invalid listId: %s", s)); - } - } - - return pairs; - } -} +/* + * Copyright (c) 2013-2023 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.controllers; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.ExportException; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.FolderArchiveDataTypes; +import org.labkey.api.admin.FolderExportContext; +import org.labkey.api.admin.StaticLoggerGetter; +import org.labkey.api.attachments.AttachmentForm; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.audit.view.AuditChangesView; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.defaults.ClearDefaultValuesAction; +import org.labkey.api.defaults.DomainIdForm; +import org.labkey.api.defaults.SetDefaultValuesAction; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.list.ListUrls; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.lists.permissions.ManagePicklistsPermission; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.UserSchema; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpPostRedirectView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.ZipFile; +import org.labkey.list.model.ListAuditProvider; +import org.labkey.list.model.ListDef; +import org.labkey.list.model.ListDefinitionImpl; +import org.labkey.list.model.ListDomainKindProperties; +import org.labkey.list.model.ListManager; +import org.labkey.list.model.ListManagerSchema; +import org.labkey.list.model.ListWriter; +import org.labkey.list.view.ListDefinitionForm; +import org.labkey.list.view.ListItemAttachmentParent; +import org.labkey.list.view.ListQueryForm; +import org.labkey.list.view.ListQueryView; +import org.springframework.beans.PropertyValue; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.ModelAndView; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +public class ListController extends SpringActionController +{ + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(ListController.class, ClearDefaultValuesAction.class); + + public ListController() + { + setActionResolver(_actionResolver); + } + + + private void addRootNavTrail(NavTree root) + { + addRootNavTrail(root, getContainer(), getUser()); + } + + public static class ListUrlsImpl implements ListUrls + { + @Override + public ActionURL getManageListsURL(Container c) + { + return new ActionURL(ListController.BeginAction.class, c); + } + + @Override + public ActionURL getCreateListURL(Container c) + { + return new ActionURL(EditListDefinitionAction.class, c); + } + } + + + public static void addRootNavTrail(NavTree root, Container c, User user) + { + if (c.hasOneOf(user, DesignListPermission.class, PlatformDeveloperPermission.class)) + { + root.addChild("Lists", getBeginURL(c)); + } + } + + + private void addListNavTrail(NavTree root, ListDefinition list, @Nullable String title) + { + addRootNavTrail(root); + root.addChild(list.getName(), list.urlShowData()); + + if (null != title) + root.addChild(title); + } + + + public static ActionURL getBeginURL(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public PageConfig defaultPageConfig() + { + PageConfig config = super.defaultPageConfig(); + return config.setHelpTopic("lists"); + } + + @RequiresPermission(ReadPermission.class) + public static class BeginAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm queryForm, BindException errors) + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), ListManagerSchema.SCHEMA_NAME); + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, ListManagerSchema.LIST_MANAGER); + + // users should see all lists without a category and public picklists and any lists they created. + SimpleFilter filter = new SimpleFilter(); + + SQLFragment sql = new SQLFragment("Category IS NULL OR Category = ") + .appendValue(ListDefinition.Category.PublicPicklist) + .append(" OR CreatedBy = ").appendValue(getUser().getUserId()); + filter.addWhereClause(sql, FieldKey.fromParts("Category"), FieldKey.fromParts("CreatedBy")); + settings.setBaseFilter(filter); + + if (null == StringUtils.trimToNull(settings.getContainerFilterName())) + settings.setContainerFilterName(ContainerFilter.Type.CurrentPlusProjectAndShared.name()); + + return schema.createView(getViewContext(), settings, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Available Lists"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowListDefinitionAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(ListDefinitionForm listDefinitionForm) + { + if (listDefinitionForm.getListId() == null) + { + throw new NotFoundException(); + } + return new ActionURL(EditListDefinitionAction.class, getContainer()).addParameter("listId", listDefinitionForm.getListId().intValue()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetListPropertiesAction extends ReadOnlyApiAction + { + @Override + public Object execute(ListDefinitionForm form, BindException errors) throws Exception + { + ListDomainKindProperties properties = ListManager.get().getListDomainKindProperties(getContainer(), form.getListId()); + if (properties != null) + return properties; + else + throw new NotFoundException("List does not exist in this container for listId " + form.getListId() + "."); + } + } + + @RequiresPermission(DesignListPermission.class) + public class EditListDefinitionAction extends SimpleViewAction + { + private ListDefinition _list; + String listDesignerHeader = "List Designer"; + + @Override + public ModelAndView getView(ListDefinitionForm form, BindException errors) + { + _list = null; + boolean createList = (null == form.getListId() || 0 == form.getListId()) && form.getName() == null; + if (!createList) + _list = form.getList(); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("listDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + if (null == _list) + { + root.addChild(listDesignerHeader); + } + else + { + addListNavTrail(root, _list, listDesignerHeader); + } + } + } + + @RequiresAnyOf({DesignListPermission.class, ManagePicklistsPermission.class}) + public static class DeleteListDefinitionAction extends ConfirmAction + { + private boolean canDelete(Container listContainer, int listId) + { + ListDef listDef = ListManager.get().getList(listContainer, listId); + ListDefinitionImpl list = ListDefinitionImpl.of(listDef); + + if (list == null) + return false; + + boolean isPicklist = listDef.getCategory() != null; + if (isPicklist) + { + boolean isOwnPicklist = listDef.getCreatedBy() == getUser().getUserId(); + return isOwnPicklist || (listDef.getCategory() == ListDefinition.Category.PublicPicklist && list.getContainer().hasPermission(getUser(), AdminPermission.class)); + } + + return list.getContainer().hasPermission(getUser(), DesignListPermission.class); + } + + @Override + public String getConfirmText() + { + return "Confirm Delete"; + } + + @Override + public void validateCommand(ListDeletionForm form, Errors errors) + { + if (form.getListId() != null) + { + if (canDelete(getContainer(), form.getListId())) + form.getListContainerMap().add(Pair.of(form.getListId(), getContainer())); + else + errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", form.getListId(), getContainer().getName())); + } + else if (form.getName() != null) + { + var list = form.getList(); + if (canDelete(list.getContainer(), list.getListId())) + form.getListContainerMap().add(Pair.of(list.getListId(), getContainer())); + else + errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", list.getListId(), getContainer().getName())); + } + else + { + List errorMessages = new ArrayList<>(); + Collection listIDs; + if (form.getListIds() != null) + listIDs = form.getListIds(); + else + listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); + + for (Pair pair : getListIdContainerPairs(listIDs, getContainer(), errorMessages)) + { + var listId = pair.first; + var listContainer = pair.second; + + if (canDelete(listContainer, listId)) + form.getListContainerMap().add(pair); + else + errorMessages.add(String.format("You do not have permission to delete list %s in container %s", listId, listContainer.getName())); + } + + if (!errorMessages.isEmpty()) + errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); + } + + if (form.getListContainerMap().isEmpty()) + errors.reject(ERROR_MSG, "You must specify a list or lists to delete."); + } + + @Override + public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Confirm Deletion"); + return new JspView<>("/org/labkey/list/view/deleteListDefinition.jsp", form, errors); + } + + @Override + public boolean handlePost(ListDeletionForm form, BindException errors) + { + for (Pair pair : form.getListContainerMap()) + { + ListDefinition listDefinition = ListService.get().getList(pair.second, pair.first); + if (null != listDefinition) + { + try + { + listDefinition.delete(getUser()); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, "Error deleting list '" + listDefinition.getName() + "'; another user may have deleted it."); + } + } + } + + return !errors.hasErrors(); + } + + @Override @NotNull + public URLHelper getSuccessURL(ListDeletionForm form) + { + return form.getReturnUrlHelper(getBeginURL(getContainer())); + } + } + + public static class ListDeletionForm extends ListDefinitionForm + { + private List _listIds; + private final List> _listContainerMap = new ArrayList<>(); + + public List getListIds() + { + return _listIds; + } + + public void setListIds(List listIds) + { + _listIds = listIds; + } + + public List> getListContainerMap() + { + return _listContainerMap; + } + } + + @RequiresPermission(AdminPermission.class) + public static class TruncateListDataAction extends ConfirmAction + { + private boolean canTruncate(Container listContainer, int listId) + { + ListDef listDef = ListManager.get().getList(listContainer, listId); + ListDefinitionImpl list = ListDefinitionImpl.of(listDef); + + if (list == null || !list.getAllowDelete()) + return false; + + return list.getContainer().hasPermission(getUser(), AdminPermission.class); + } + + @Override + public String getConfirmText() + { + return "Confirm Delete All Data"; + } + + @Override + public void validateCommand(ListDeletionForm form, Errors errors) + { + Container currentContainer = getContainer(); + List errorMessages = new ArrayList<>(); + Collection listIDs; + if (form.getListIds() != null) + listIDs = form.getListIds(); + else + listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); + + for (Pair pair : getListIdContainerPairs(listIDs, currentContainer, errorMessages)) + { + var listId = pair.first; + var listContainer = pair.second; + + if (canTruncate(listContainer, listId)) + { + form.getListContainerMap().add(pair); + } + else + errorMessages.add(String.format("You do not have permission to delete data for list %s in container %s", listId, listContainer.getName())); + } + + if (!errorMessages.isEmpty()) + errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); + + if (form.getListContainerMap().isEmpty()) + errors.reject(ERROR_MSG, "You must specify a list or lists to delete data from."); + } + + @Override + public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Confirm Delete All Data"); + return new JspView<>("/org/labkey/list/view/truncateListData.jsp", form, errors); + } + + @Override + public boolean handlePost(ListDeletionForm form, BindException errors) + { + Container containerDataToDelete = getContainer(); + for (Pair pair : form.getListContainerMap()) + { + Container listDefContainer = pair.second; + ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first); + if (null != listDef) + { + try + { + TableInfo table = listDef.getTable(getUser(), listDefContainer); + if (table != null && table.getUpdateService() != null) + table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage()); + } + } + } + + return !errors.hasErrors(); + } + + @Override @NotNull + public URLHelper getSuccessURL(ListDeletionForm form) + { + return form.getReturnUrlHelper(getBeginURL(getContainer())); + } + } + + @RequiresPermission(ReadPermission.class) + public class GridAction extends SimpleViewAction + { + private ListDefinition _list; + private String _title; + + @Override + public ModelAndView getView(ListQueryForm form, BindException errors) + { + _list = form.getList(); + if (null == _list) + throw new NotFoundException("List does not exist in this container"); + + if (!_list.isVisible(getUser())) + throw new UnauthorizedException("User is not allowed to see this list."); + + ListQueryView view = new ListQueryView(form, errors); + + TableInfo ti = view.getTable(); + if (ti != null) + { + _title = ti.getTitle(); + } + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, _title); + } + } + + + public abstract static class InsertUpdateAction extends FormViewAction + { + protected abstract ActionURL getActionView(ListDefinition list, BindException errors); + protected abstract Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues); + + @Override + public void validateCommand(ListDefinitionForm form, Errors errors) + { + /* No-op */ + } + + @Override + public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) + { + ListDefinition list = form.getList(); // throws NotFoundException + + ActionURL url = getActionView(list, errors); + Collection> inputs = getInputs(list, url, getPropertyValues().getPropertyValues()); + + if (getViewContext().getRequest().getMethod().equalsIgnoreCase("POST")) + { + getPageConfig().setTemplate(PageConfig.Template.None); + return new HttpPostRedirectView(url.toString(), inputs); + } + + throw new RedirectException(url); + } + + @Override + public boolean handlePost(ListDefinitionForm form, BindException errors) + { + return true; + } + + @Override + public URLHelper getSuccessURL(ListDefinitionForm form) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + /** + * DO NOT USE. This action has been deprecated in 13.2 in favor of the standard query/insertQueryRow action. + * Only here for backwards compatibility to resolve requests and redirect. + */ + @Deprecated + @RequiresPermission(InsertPermission.class) + public class InsertAction extends InsertUpdateAction + { + @Override + protected ActionURL getActionView(ListDefinition list, BindException errors) + { + TableInfo listTable = list.getTable(getUser()); + return listTable.getUserSchema().getQueryDefForTable(listTable.getName()).urlFor(QueryAction.insertQueryRow, getContainer()); + } + + @Override + protected Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues) + { + Collection> inputs = new ArrayList<>(); + + for (PropertyValue value : propertyValues) + { + if (value.getName().equals(ActionURL.Param.returnUrl.toString())) + { + url.addParameter(ActionURL.Param.returnUrl, (String) value.getValue()); + } + else + inputs.add(Pair.of(value.getName(), value.getValue().toString())); + } + + return inputs; + } + } + + public static class ListDetailsForm extends ListDefinitionForm + { + private Object _pk; + + public Object getPk() + { + return _pk; + } + + public void setPk(Object pk) + { + _pk = pk; + } + } + + @RequiresPermission(ReadPermission.class) + public class DetailsAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListDetailsForm form, BindException errors) + { + _list = form.getList(); + TableInfo table = _list.getTable(getUser(), getContainer()); + + if (null == table) + throw new NotFoundException("List does not exist"); + + ListQueryUpdateForm tableForm = new ListQueryUpdateForm(table, getViewContext(), _list, form.getPk(), errors); + DetailsView details = new DetailsView(tableForm); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + ActionButton gridButton; + ActionURL gridUrl = _list.urlShowData(getViewContext().getContainer()); + gridButton = new ActionButton("Show Grid", gridUrl); + + if (table.hasPermission(getUser(), UpdatePermission.class)) + { + ActionURL updateUrl = _list.urlUpdate(getUser(), getContainer(), tableForm.getPkVal(), gridUrl); + ActionButton editButton = new ActionButton("Edit", updateUrl); + bb.add(editButton); + } + + bb.add(gridButton); + details.getDataRegion().setButtonBar(bb); + + VBox view = new VBox(); + ListItem item; + item = _list.getListItem(tableForm.getPkVal(), getUser(), getContainer()); + + if (null == item) + throw new NotFoundException("List item '" + tableForm.getPkVal() + "' does not exist"); + + view.addView(details); + + if (form.isShowHistory()) + { + WebPartView linkView = new HtmlView(LinkBuilder.labkeyLink("hide item history", getViewContext().cloneActionURL().deleteParameter("showHistory")).build()); + linkView.setFrame(WebPartView.FrameType.NONE); + view.addView(linkView); + + UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); + if (schema != null) + { + QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(FieldKey.fromParts(ListAuditProvider.COLUMN_NAME_LIST_ITEM_ENTITY_ID), item.getEntityId()); + + settings.setBaseFilter(filter); + settings.setQueryName(ListManager.LIST_AUDIT_EVENT); + QueryView history = schema.createView(getViewContext(), settings, errors); + + history.setTitle("List Item History:"); + history.setFrame(WebPartView.FrameType.NONE); + view.addView(history); + } + } + else + { + view.addView(new HtmlView(LinkBuilder.labkeyLink("show item history", getViewContext().cloneActionURL().addParameter("showHistory", "1")).build())); + } + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, "View List Item"); + } + } + + + // Override to ensure that pk value type matches column type. This is critical for PostgreSQL 8.3. + public static class ListQueryUpdateForm extends QueryUpdateForm + { + private final ListDefinition _list; + private final Object _pk; + + public ListQueryUpdateForm(TableInfo table, ViewContext ctx, ListDefinition list, @Nullable Object pk, BindException errors) + { + super(table, ctx, errors); + _list = list; + _pk = pk; + } + + @Override + public Object[] getPkVals() + { + if (_pk != null) + { + return new Object[]{_pk}; + } + else + { + Object[] pks = super.getPkVals(); + assert 1 == pks.length; + pks[0] = _list.getKeyType().convertKey(pks[0]); + return pks; + } + } + + public Domain getDomain() + { + return _list != null ? _list.getDomain() : null; + } + } + + + // Users can change the PK of a list item, so we don't want to store PK in discussion source URL (back link + // from announcements to the object). Instead, we tell discussion service to store a URL with ListId and + // EntityId. This action resolves to the current details URL for that item. + @RequiresPermission(ReadPermission.class) + public class ResolveAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(ListDefinitionForm form) + { + ListDefinition list = form.getList(); + ListItem item = list.getListItemForEntityId(getViewContext().getActionURL().getParameter("entityId"), getUser()); // TODO: Use proper form, validate + ActionURL url = getViewContext().cloneActionURL().setAction(DetailsAction.class); // Clone to preserve discussion params + url.deleteParameter("entityId"); + url.addParameter("pk", item.getKey().toString()); + + return url; + } + } + + + @RequiresPermission(InsertPermission.class) + public class UploadListItemsAction extends AbstractQueryImportAction + { + private ListDefinition _list; + private QueryUpdateService.InsertOption _insertOption; + + @Override + protected void initRequest(ListDefinitionForm form) throws ServletException + { + _list = form.getList(); + _insertOption = form.getInsertOption(); + setTarget(_list.getTableForInsert(getUser(), getContainer())); + } + + @Override + public ModelAndView getView(ListDefinitionForm form, BindException errors) throws Exception + { + initRequest(form); + boolean allowImportOptions = _list.getKeyType() != ListDefinition.KeyType.AutoIncrementInteger; + setShowMergeOption(allowImportOptions); + setShowUpdateOption(allowImportOptions); + setSuccessMessageSuffix("imported"); + return getDefaultImportView(form, errors); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + return _list.importListItems(getUser(), getContainer(), dl, errors, null, null, false, getLookupResolutionType(), _insertOption); + } + + @Override + protected void validatePermission(User user, BindException errors) + { + super.validatePermission(user, errors); + if (!_list.getAllowUpload()) + errors.reject(SpringActionController.ERROR_MSG, "This list does not allow uploading data"); + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, "Import Data"); + } + } + + + @RequiresPermission(ReadPermission.class) + public class HistoryAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListQueryForm form, BindException errors) + { + _list = form.getList(); + if (_list != null) + { + UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); + if (schema != null) + { + VBox box = new VBox(); + String domainUri = _list.getDomain().getTypeURI(); + + // list audit events + QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + SimpleFilter eventFilter = new SimpleFilter(); + eventFilter.addCondition(FieldKey.fromParts(ListManager.LISTID_FIELD_NAME), _list.getListId()); + settings.setBaseFilter(eventFilter); + settings.setQueryName(ListManager.LIST_AUDIT_EVENT); + + QueryView view = schema.createView(getViewContext(), settings, errors); + view.setTitle("List Events"); + box.addView(view); + + // domain audit events associated with this list + QuerySettings domainSettings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + + SimpleFilter domainFilter = new SimpleFilter(); + domainFilter.addCondition(FieldKey.fromParts(DomainAuditProvider.COLUMN_NAME_DOMAIN_URI), domainUri); + domainSettings.setBaseFilter(domainFilter); + + domainSettings.setQueryName(DomainAuditProvider.EVENT_TYPE); + QueryView domainView = schema.createView(getViewContext(), domainSettings, errors); + + domainView.setTitle("List Design Changes"); + box.addView(domainView); + + return box; + } + return HtmlView.of("Unable to create the List history view"); + } + else + return HtmlView.of("Unable to find the specified List"); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_list != null) + addListNavTrail(root, _list, _list.getName() + ":History"); + else + root.addChild(":History"); + } + } + + private String getUrlParam(Enum param) + { + String s = getViewContext().getActionURL().getParameter(param); + ReturnUrlForm form = new ReturnUrlForm(); + form.setReturnUrl(s); + return form.getReturnUrl(); + } + + public static class ListItemDetailsForm + { + private Integer _listId; + private String _name; + private Integer _rowId; + + public Integer getListId() + { + return _listId; + } + + public void setListId(Integer listId) + { + _listId = listId; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + } + + @RequiresPermission(ReadPermission.class) + public class ListItemDetailsAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListItemDetailsForm form, BindException errors) + { + String listName = form.getName(); + if (listName != null) + _list = ListService.get().getList(getContainer(), listName, true); + + if (_list == null) + { + Integer listId = form.getListId(); + if (listId != null && listId > 0) + _list = ListService.get().getList(getContainer(), listId); + } + + if (_list == null) + return HtmlView.of("This list is no longer available."); + + String comment = null; + String oldRecord = null; + String newRecord = null; + + Integer eventRowId = form.getRowId(); + if (eventRowId == null || eventRowId <= 0) + return HtmlView.of("Unable to resolve event details. An event \"rowId\" must be specified."); + + ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), ListManager.LIST_AUDIT_EVENT, eventRowId); + + if (event != null) + { + comment = event.getComment(); + oldRecord = event.getOldRecordMap(); + newRecord = event.getNewRecordMap(); + } + + if (!StringUtils.isEmpty(oldRecord) || !StringUtils.isEmpty(newRecord)) + { + Map oldData = ListAuditProvider.decodeFromDataMap(oldRecord); + Map newData = ListAuditProvider.decodeFromDataMap(newRecord); + + String srcUrl = getUrlParam(ActionURL.Param.redirectUrl); + if (srcUrl == null) + srcUrl = getUrlParam(ActionURL.Param.returnUrl); + if (srcUrl == null) + srcUrl = _list.urlFor(ListController.HistoryAction.class, getContainer()).getLocalURIString(); + AuditChangesView view = new AuditChangesView(comment, oldData, newData); + view.setReturnUrl(srcUrl); + + return view; + } + else + return HtmlView.of("No details available for this event."); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_list != null) + addListNavTrail(root, _list, "List Item Details"); + else + root.addChild("List Item Details"); + } + } + + + public static class ListAttachmentForm extends AttachmentForm + { + private int _listId; + + public int getListId() + { + return _listId; + } + + public void setListId(int listId) + { + _listId = listId; + } + } + + + public static ActionURL getDownloadURL(ListDefinition list, String rowEntityId, String name) + { + return new ActionURL(DownloadAction.class, list.getContainer()) + .addParameter("listId", list.getListId()) + .addParameter("entityId", rowEntityId) + .addParameter("name", name); + } + + @RequiresPermission(ReadPermission.class) + public static class DownloadAction extends BaseDownloadAction + { + @Override + public void validate(ListAttachmentForm form, BindException errors) + { + if (!GUID.isGUID(form.getEntityId())) + { + errors.rejectValue("entityId", ERROR_MSG, "entityId is not a GUID: " + form.getEntityId()); + } + } + + @Nullable + @Override + public Pair getAttachment(ListAttachmentForm form) + { + ListDefinitionImpl listDef = (ListDefinitionImpl)ListService.get().getList(getContainer(), form.getListId()); + if (listDef == null) + throw new NotFoundException("List does not exist in this container"); + + Container dataContainer = listDef.getListItemContainerForDownload(form.getEntityId(), getUser(), ReadPermission.class); + if (dataContainer == null) + throw new NotFoundException("List does not have an item for the entityid"); + + AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), dataContainer); + + return new Pair<>(parent, form.getName()); + } + } + + + @RequiresPermission(DesignListPermission.class) + public static class ExportListArchiveAction extends ExportAction + { + @Override + public void export(ListDefinitionForm form, HttpServletResponse response, BindException errors) throws Exception + { + Container c = getContainer(); + List errorMessages = new ArrayList<>(); + Set selection = DataRegionSelection.getSelected(form.getViewContext(), false); + List> selectedLists = new LinkedList<>(); + Map duplicateNames = new HashMap<>(); + + for (Pair pair : getListIdContainerPairs(selection, c, errorMessages)) + { + String listName = Objects.requireNonNull(ListManager.get().getList(pair.second, pair.first)).getName(); + + //Display simple error to the user when Lists with the same names are selected. + if (duplicateNames.containsKey(listName)) + { + errors.reject(ERROR_MSG, "'" + listName + "' is already selected, please select Lists with unique names to Export."); + throw new ExportException(new SimpleErrorView(errors, true)); + } + else + { + duplicateNames.put(listName, pair.first); + } + // Issue 47289: Export List Archive if the user is an Admin of the folders of the selected Lists, else throw Permission error + if (!pair.second.hasPermission(getUser(), DesignListPermission.class)) + { + errors.reject(ERROR_MSG, String.format("List archive export is only supported for Lists in folders where you are an administrator. Try filtering to select only Lists in the local folder.")); + throw new ExportException(new SimpleErrorView(errors, true)); + } + selectedLists.add(pair); + } + + Set dataTypes = PageFlowUtil.set(FolderArchiveDataTypes.LIST_DESIGN, FolderArchiveDataTypes.LIST_DATA); + FolderExportContext ctx = new FolderExportContext(getUser(), c, dataTypes, "List Export", new StaticLoggerGetter(LogHelper.getLogger(ListController.class, "Export List Archive"))); + ctx.setLists(selectedLists); + ListWriter writer = new ListWriter(); + + // Export to a temporary file first so exceptions are displayed by the standard error page, Issue #44152 + // Same pattern as ExportFolderAction + Path tempDir = FileUtil.getTempDirectory().toPath(); + String filename = FileUtil.makeFileNameWithTimestamp(c.getName(), "lists.zip"); + + try (ZipFile zip = new ZipFile(tempDir, filename)) + { + writer.write(getUser(), zip, ctx); + } + + Path tempZipFile = tempDir.resolve(filename); + + // No exceptions, so stream the resulting zip file to the browser and delete it + try (OutputStream os = ZipFile.getOutputStream(getViewContext().getResponse(), filename)) + { + Files.copy(tempZipFile, os); + } + finally + { + Files.delete(tempZipFile); + } + } + } + + + @RequiresPermission(DesignListPermission.class) + public class ImportListArchiveAction extends FormViewAction + { + @Override + public void validateCommand(ListDefinitionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/list/view/importLists.jsp", null, errors); + } + + @Override + public boolean handlePost(ListDefinitionForm form, BindException errors) throws Exception + { + Map map = getFileMap(); + + if (map.isEmpty()) + { + errors.reject("listImport", "You must select a .list.zip file to import."); + } + else if (map.size() > 1) + { + errors.reject("listImport", "Only one file is allowed."); + } + else + { + MultipartFile file = map.values().iterator().next(); + + if (0 == file.getSize() || StringUtils.isBlank(file.getOriginalFilename())) + { + errors.reject("listImport", "You must select a .list.zip file to import."); + } + else + { + ListService.get().importListArchive(file.getInputStream(), errors, getContainer(), getUser()); + } + } + + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(ListDefinitionForm form) + { + return form.getReturnActionURL( getBeginURL(getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Import List Archive"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseListsAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + response.put("lists", getJSONLists(ListService.get().getLists(getContainer(), getUser(), true))); + response.put("success", true); + + return response; + } + + private List getJSONLists(Map lists){ + List listsJSON = new ArrayList<>(); + for(ListDefinition def : new TreeSet<>(lists.values())){ + JSONObject listObj = new JSONObject(); + listObj.put("name", def.getName()); + listObj.put("id", def.getListId()); + listObj.put("description", def.getDescription()); + listsJSON.add(listObj); + } + return listsJSON; + } + } + + @RequiresPermission(DesignListPermission.class) + public static class SetDefaultValuesListAction extends SetDefaultValuesAction + { + } + + /** + * Utility method to parse out Pair from a Collection where the strings are encoded + * pairs of listIds and container entityIds separated (e.g. "12,ff72c81e-ce2d-103a-b3ce-e8f660509016"). + */ + private static List> getListIdContainerPairs( + Collection listIdContainers, + Container currentContainer, + Collection errors) + { + List> pairs = new ArrayList<>(); + + for (String s : listIdContainers) + { + String[] parts = s.split(","); + Container c; + if (parts.length > 1) + c = ContainerManager.getForId(parts[1]); + else + c = currentContainer; + if (c == null) + { + errors.add(String.format("Container not found for %s", s)); + continue; + } + + try + { + int listId = Integer.parseInt(parts[0]); + pairs.add(Pair.of(listId, c)); + } + catch (NumberFormatException badListId) + { + errors.add(String.format("Invalid listId: %s", s)); + } + } + + return pairs; + } +} diff --git a/list/src/org/labkey/list/model/ListDefinitionImpl.java b/list/src/org/labkey/list/model/ListDefinitionImpl.java index b63b108c2e9..0eb1d40090e 100644 --- a/list/src/org/labkey/list/model/ListDefinitionImpl.java +++ b/list/src/org/labkey/list/model/ListDefinitionImpl.java @@ -1,834 +1,852 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.LookupResolutionType; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.ImportProgress; -import org.labkey.api.exp.DomainNotFoundException; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.TemplateInfo; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.DomainUtil; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.MapLoader; -import org.labkey.api.security.User; -import org.labkey.api.util.ReentrantLockWithName; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.writer.VirtualFile; -import org.labkey.list.controllers.ListController; -import org.springframework.web.servlet.mvc.Controller; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; - -import static org.labkey.api.util.GUID.makeGUID; - -public class ListDefinitionImpl implements ListDefinition -{ - private static final Logger LOG = LogManager.getLogger(ListDefinitionImpl.class); - - @Nullable - static public ListDefinitionImpl of(@Nullable ListDef def) - { - if (def == null) - return null; - return new ListDefinitionImpl(def); - } - - private boolean _new; - // If set to a collection of IDs, we'll attempt to use them (in succession) as the list ID on insert - private Collection _preferredListIds = Collections.emptyList(); - private Domain _domain; - - ListDef.ListDefBuilder _def; - - - public ListDefinitionImpl(ListDef def) - { - _def = new ListDef.ListDefBuilder(def); - } - - - public ListDefinitionImpl(Container container, String name, KeyType keyType, @Nullable Category category, TemplateInfo templateInfo) - { - _new = true; - ListDef.ListDefBuilder builder = new ListDef.ListDefBuilder(); - builder.setContainer(container.getId()); - builder.setName(name); - builder.setEntityId(makeGUID()); - builder.setKeyType(keyType.toString()); - builder.setCategory(category); - _def = builder; - Lsid lsid = ListDomainKind.generateDomainURI(container, keyType, category); - _domain = PropertyService.get().createDomain(container, lsid.toString(), name, templateInfo); - } - - // For new lists only, we'll attempt to use these IDs at insert time - @Override - public void setPreferredListIds(Collection preferredListIds) - { - _preferredListIds = preferredListIds; - } - - @Override - public int getListId() - { - return _def.getListId(); - } - - public String getEntityId() - { - return _def.getEntityId(); - } - - @Override - public Container getContainer() - { - return ContainerManager.getForId(_def.getContainerId()); - } - - @Override - @Nullable - public Domain getDomain() - { - return getDomain(false); - } - - @Override - @Nullable - public Domain getDomain(boolean forUpdate) - { - if (_domain == null || (forUpdate && !_domain.isMutable())) // assure we have a mutable domain if needed, but don't ditch a mutable one because it may not have been saved yet - { - _domain = PropertyService.get().getDomain(_def.getDomainId(), forUpdate); - } - return _domain; - } - - @Override - public @NotNull Domain getDomainOrThrow() - { - return getDomainOrThrow(false); - } - - @Override - public @NotNull Domain getDomainOrThrow(boolean forUpdate) - { - var domain = getDomain(forUpdate); - if (domain == null) - throw new IllegalArgumentException("Could not find domain for list \"" + getName() + "\"."); - return domain; - } - - @Override - public String getName() - { - return _def.getName(); - } - - @Override - public String getKeyName() - { - return _def.getKeyName(); - } - - @Override - public void setKeyName(String name) - { - if (_def.getTitleColumn() != null && _def.getTitleColumn().equals(getKeyName())) - { - edit().setTitleColumn(name); - } - edit().setKeyName(name); - } - - @Override - public void setDescription(String description) - { - edit().setDescription(description); - } - - @Override - public KeyType getKeyType() - { - return KeyType.valueOf(_def.getKeyType()); - } - - @Override - public void setKeyType(KeyType type) - { - edit().setKeyType(type.toString()); - } - - @Override - public Category getCategory() - { - return _def.getCategory(); - } - - @Override - public void setCategory(Category category) - { - edit().setCategory(category); - } - - @Override - public int getCreatedBy() { return _def.getCreatedBy(); } - - @Override - public boolean getAllowDelete() - { - return _def.getAllowDelete(); - } - - @Override - public void setAllowDelete(boolean allowDelete) - { - edit().setAllowDelete(allowDelete); - } - - @Override - public boolean getAllowUpload() - { - return _def.getAllowUpload(); - } - - @Override - public void setAllowUpload(boolean allowUpload) - { - edit().setAllowUpload(allowUpload); - } - - @Override - public boolean getAllowExport() - { - return _def.getAllowExport(); - } - - @Override - public void setAllowExport(boolean allowExport) - { - edit().setAllowExport(allowExport); - } - - @Override - public boolean getEntireListIndex() - { - return _def.isEntireListIndex(); - } - - @Override - public void setEntireListIndex(boolean eachItemIndex) - { - edit().setEntireListIndex(eachItemIndex); - } - - @Override - public IndexSetting getEntireListIndexSetting() - { - return _def.getEntireListIndexSettingEnum(); - } - - @Override - public void setEntireListIndexSetting(IndexSetting setting) - { - edit().setEntireListIndexSettingEnum(setting); - } - - @Override - public @Nullable String getEntireListTitleTemplate() - { - return _def.getEntireListTitleTemplate(); - } - - @Override - public void setEntireListTitleTemplate(@Nullable String template) - { - edit().setEntireListTitleTemplate(template); - } - - @Override - public BodySetting getEntireListBodySetting() - { - return _def.getEntireListBodySettingEnum(); - } - - @Override - public void setEntireListBodySetting(BodySetting setting) - { - edit().setEntireListBodySettingEnum(setting); - } - - @Override - public String getEntireListBodyTemplate() - { - return _def.getEntireListBodyTemplate(); - } - - @Override - public void setEntireListBodyTemplate(String template) - { - edit().setEntireListBodyTemplate(template); - } - - @Override - public boolean getEachItemIndex() - { - return _def.isEachItemIndex(); - } - - @Override - public void setEachItemIndex(boolean eachItemIndex) - { - edit().setEachItemIndex(eachItemIndex); - } - - @Override - public @Nullable String getEachItemTitleTemplate() - { - return _def.getEachItemTitleTemplate(); - } - - @Override - public void setEachItemTitleTemplate(@Nullable String template) - { - edit().setEachItemTitleTemplate(template); - } - - @Override - public BodySetting getEachItemBodySetting() - { - return _def.getEachItemBodySettingEnum(); - } - - @Override - public void setEachItemBodySetting(BodySetting setting) - { - edit().setEachItemBodySettingEnum(setting); - } - - @Override - public String getEachItemBodyTemplate() - { - return _def.getEachItemBodyTemplate(); - } - - @Override - public void setEachItemBodyTemplate(String template) - { - edit().setEachItemBodyTemplate(template); - } - - @Override - public boolean getFileAttachmentIndex() - { - return _def.isFileAttachmentIndex(); - } - - @Override - public void setFileAttachmentIndex(boolean fileAttachmentIndex) - { - edit().setFileAttachmentIndex(fileAttachmentIndex); - } - - - @Override - public void save(User user) throws Exception - { - save(user, true, null, null); - } - - private static final ReentrantLock _saveLock = new ReentrantLockWithName(ListDefinitionImpl.class, "_saveLock"); - - @Override - public void save(User user, boolean ensureKey, @Nullable Map newRecordMap, @Nullable List calculatedFields) throws Exception - { - if (ensureKey) - { - assert getKeyName() != null : "Key not provided for List: " + getName(); - assert getKeyType() != null : "Invalid Key Type for List: " + getName(); - } - - try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction(_saveLock)) - { - if (ensureKey) - ensureKey(); - - Domain domain = getDomain(true); - - if (_new) - { - // The domain kind cannot lookup the list definition if the domain has not been saved - ((ListDomainKind) domain.getDomainKind()).setListDefinition(this); - - domain.save(user, newRecordMap, calculatedFields); - - _def.setDomainId(domain.getTypeId()); - ListDef inserted = ListManager.get().insert(user, _def, _preferredListIds); - _def = new ListDef.ListDefBuilder(inserted); - _new = false; - - ListManager.get().addAuditEvent(this, user, String.format("The list %s was created", _def.getName())); - } - else - { - ListDef updated = ListManager.get().update(user, _def); - _def = new ListDef.ListDefBuilder(updated); - ListManager.get().addAuditEvent(this, user, String.format("The definition of the list %s was modified", _def.getName())); - } - - transaction.commit(); - } - catch (RuntimeSQLException e) - { - if (RuntimeSQLException.isConstraintException(e.getSQLException())) - throw new ValidationException("The name '" + _def.getName() + "' is already in use."); - throw e; - } - - // Fetch the domain again to prime the cache, reducing potential for DB deadlocks - _domain = null; - getDomain(); - - ListManager.get().indexList(_def); - } - - private void ensureKey() - { - Domain domain = getDomain(); - for (DomainProperty dp : domain.getProperties()) - { - if (dp.getName().equalsIgnoreCase(getKeyName())) - return; - } - - DomainProperty prop = domain.addProperty(); - prop.setPropertyURI(DomainUtil.createUniquePropertyURI(domain.getTypeURI())); - prop.setName(getKeyName()); - prop.setType(PropertyService.get().getType(domain.getContainer(), getKeyType().getPropertyType().getXmlName())); - - domain.setPropertyIndex(prop, 0); - } - - @Override - public ListItem createListItem() - { - return new ListItemImpl(this); - } - - @Override - public ListItem getListItem(Object key, User user) - { - return getListItem(key, user, getContainer()); - } - - @Override - public ListItem getListItem(Object key, User user, Container c) - { - // Convert key value to the proper type, since PostgreSQL 8.3 requires that key parameter types match their column types. - Object typedKey = getKeyType().convertKey(key); - - return getListItem(new SimpleFilter(FieldKey.fromParts(getKeyName()), typedKey), user, c); - } - - @Override - public ListItem getListItemForEntityId(String entityId, User user) - { - return getListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer()); - } - - private ListItem getListItem(SimpleFilter filter, User user, Container c) - { - TableInfo tbl = new ListQuerySchema(user, c).getTable(getName()); - - if (null == tbl) - return null; - - Map row = null; - - try - { - row = new TableSelector(tbl, filter, null).getMap(); - } - catch (IllegalStateException e) - { - /* more than one row matches */ - } - - if (row == null) - return null; - - ListItm itm = new ListItm(); - - itm.setListId(getListId()); - itm.setEntityId(row.get("EntityId").toString()); - itm.setKey(row.get(getKeyName())); - - ListItemImpl impl = new ListItemImpl(this, itm); - for (DomainProperty prop : getDomainOrThrow().getProperties()) - { - impl.setProperty(prop, row.get(prop.getName())); - } - - return impl; - } - - public boolean hasListItemForEntityId(String entityId, User user) - { - return hasListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer()); - } - - private boolean hasListItem(SimpleFilter filter, User user, Container c) - { - TableInfo tbl = getTable(user, c); - - if (null == tbl) - return false; - - return new TableSelector(tbl, filter, null).exists(); - } - - @Override - public void delete(User user) throws DomainNotFoundException - { - delete(user, null); - } - - @Override - public void delete(User user, @Nullable String auditUserComment) throws DomainNotFoundException - { - TableInfo table = getTable(user); - QueryUpdateService qus = null; - - if (null != table) - qus = table.getUpdateService(); - - // In certain cases we may create a list that is not viable (i.e., one in which a table was never created because - // the metadata wasn't valid). Still allow deleting the list - try (DbScope.Transaction transaction = (table != null) ? table.getSchema().getScope().ensureTransaction() : - ExperimentService.get().ensureTransaction()) - { - // remove related attachments, discussions, and indices - ListManager.get().deleteIndexedList(this); - if (qus instanceof ListQueryUpdateService listQus) - listQus.deleteRelatedListData(user, getContainer()); - - // then delete the list itself - ListManager.get().deleteListDef(getContainer(), getListId()); - Domain domain = getDomainOrThrow(); - domain.delete(user, auditUserComment); - - ListManager.get().addAuditEvent(this, user, String.format("The list %s was deleted", _def.getName())); - - transaction.commit(); - } - - SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); - QueryService.get().fireQueryDeleted(user, getContainer(), null, schemaPath, Collections.singleton(getName())); - } - - - @Override - public int insertListItems(User user, Container container, List listItems) - { - BatchValidationException ve = new BatchValidationException(); - - List> rows = new ArrayList<>(); - - for (ListItem item : listItems) - { - Map row = new CaseInsensitiveHashMap<>(); - Map propertyMap = item.getProperties(); - - if (null != propertyMap) - { - for (String key : propertyMap.keySet()) - { - ObjectProperty prop = propertyMap.get(key); - if (null != prop) - { - row.put(prop.getName(), prop.getObjectValue()); - } - } - rows.add(row); - } - } - - MapLoader loader = new MapLoader(rows); - - // TODO: Find out the attachment directory? - return insertListItems(user, container, loader, ve, null, null, false, LookupResolutionType.primaryKey); - } - - - @Override - public int insertListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType) - { - return importListItems(user, container, loader, errors, attachmentDir, progress, supportAutoIncrementKey, lookupResolutionType, QueryUpdateService.InsertOption.INSERT); - } - - @Override - public int importListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType, QueryUpdateService.InsertOption insertOption) - { - ListQuerySchema schema = new ListQuerySchema(user, container); - TableInfo table = schema.getTable(_def.getName()); - if (null != table) - { - ListQueryUpdateService lqus = (ListQueryUpdateService) table.getUpdateService(); - if (null != lqus) - return lqus.insertUsingDataIterator(loader, user, container, errors, attachmentDir, progress, supportAutoIncrementKey, insertOption, lookupResolutionType); - } - return 0; - } - - - @Override - public String getDescription() - { - return _def.getDescription(); - } - - @Override - public String getTitleColumn() - { - return _def.getTitleColumn(); - } - - @Override - public void setTitleColumn(String titleColumn) - { - edit().setTitleColumn(titleColumn); - } - - @Override - public Date getModified() - { - return _def.getModified(); - } - - @Override - public void setModified(Date modified) - { - edit().setModified(modified); - } - - @Override - public Date getLastIndexed() - { - return _def.getLastIndexed(); - } - - @Override - public void setLastIndexed(Date modified) - { - edit().setLastIndexed(modified); - } - - @Override - @Nullable - public TableInfo getTable(User user) - { - return getTable(user, getContainer()); - } - - @Override - @Nullable - public TableInfo getTable(User user, Container c) - { - return getTable(user, c, null); - } - - @Override - @Nullable - public TableInfo getTable(User user, Container c, @Nullable ContainerFilter cf) - { - TableInfo table; - try - { - if (null != getDomain()) - { - // Go through the schema so we always get all the XML metadata applied - UserSchema schema = new ListQuerySchema(user, c); - table = schema.getTable(getName(), cf, true, false); - } - else - { - table = null; - LOG.warn("Failed to construct list table because domain not found"); - } - } - catch (IllegalStateException e) - { - /* Return a null table -- configuration failed */ - table = null; - LOG.warn("Failed to construct list table", e); - } - - return table; - } - - @Override - public TableInfo getTableForInsert(User user, Container c) - { - return getTable(user, c, QueryService.get().getContainerFilterForLookups(c, user)); - } - - @Override - public ActionURL urlImport(Container c) - { - return urlForName(ListController.UploadListItemsAction.class, c); - } - - @Override - public ActionURL urlShowDefinition() - { - return urlFor(ListController.EditListDefinitionAction.class, getContainer()); - } - - @Override - public ActionURL urlShowData(Container c) - { - return urlForName(ListController.GridAction.class, c); - } - - @Override - public ActionURL urlUpdate(User user, Container container, @Nullable Object pk, @Nullable URLHelper returnAndCancelUrl) - { - ActionURL url = QueryService.get().urlFor(user, container, QueryAction.updateQueryRow, ListQuerySchema.NAME, getName()); - - // Can be null if caller will be filling in pk (e.g., grid edit column) - if (null != pk) - url.addParameter("pk", pk.toString()); - - if (returnAndCancelUrl != null) - { - url.addCancelURL(returnAndCancelUrl); - url.addReturnUrl(returnAndCancelUrl); - } - - return url; - } - - @Override - public ActionURL urlDetails(@Nullable Object pk) - { - return urlDetails(pk, getContainer()); - } - - @Override - public ActionURL urlDetails(@Nullable Object pk, Container c) - { - ActionURL url = urlForName(ListController.DetailsAction.class, c); - // Can be null if caller will be filling in pk (e.g., grid edit column) - - if (null != pk) - url.addParameter("pk", pk.toString()); - - return url; - } - - @Override - public ActionURL urlShowHistory(Container c) - { - return urlFor(ListController.HistoryAction.class, c); - } - - @Override - public ActionURL urlShowData() - { - return urlShowData(getContainer()); - } - - @Override - public ActionURL urlFor(Class actionClass) - { - return urlFor(actionClass, getContainer()); - } - - @Override - public ActionURL urlFor(Class actionClass, Container c) - { - ActionURL ret = new ActionURL(actionClass, c); - ret.addParameter("listId", getListId()); - return ret; - } - - private ActionURL urlForName(Class actionClass, Container c) - { - ActionURL ret = new ActionURL(actionClass, c); - ret.addParameter("name", getName()); - return ret; - } - - private ListDef.ListDefBuilder edit() - { - return _def; - } - - @Override - public Collection getDependents(User user) - { - SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); - return QueryService.get().getQueryDependents(user, getContainer(), null, schemaPath, Collections.singleton(getName())); - } - - @Override - public String toString() - { - return getName() + ", id: " + getListId(); - } - - @Override - public int compareTo(ListDefinition l) - { - return getName().compareToIgnoreCase(l.getName()); - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.LookupResolutionType; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.ImportProgress; +import org.labkey.api.exp.DomainNotFoundException; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.TemplateInfo; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.DomainUtil; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.MapLoader; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.util.ReentrantLockWithName; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.writer.VirtualFile; +import org.labkey.list.controllers.ListController; +import org.springframework.web.servlet.mvc.Controller; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +import static org.labkey.api.util.GUID.makeGUID; + +public class ListDefinitionImpl implements ListDefinition +{ + private static final Logger LOG = LogManager.getLogger(ListDefinitionImpl.class); + + @Nullable + static public ListDefinitionImpl of(@Nullable ListDef def) + { + if (def == null) + return null; + return new ListDefinitionImpl(def); + } + + private boolean _new; + // If set to a collection of IDs, we'll attempt to use them (in succession) as the list ID on insert + private Collection _preferredListIds = Collections.emptyList(); + private Domain _domain; + + ListDef.ListDefBuilder _def; + + + public ListDefinitionImpl(ListDef def) + { + _def = new ListDef.ListDefBuilder(def); + } + + + public ListDefinitionImpl(Container container, String name, KeyType keyType, @Nullable Category category, TemplateInfo templateInfo) + { + _new = true; + ListDef.ListDefBuilder builder = new ListDef.ListDefBuilder(); + builder.setContainer(container.getId()); + builder.setName(name); + builder.setEntityId(makeGUID()); + builder.setKeyType(keyType.toString()); + builder.setCategory(category); + _def = builder; + Lsid lsid = ListDomainKind.generateDomainURI(container, keyType, category); + _domain = PropertyService.get().createDomain(container, lsid.toString(), name, templateInfo); + } + + // For new lists only, we'll attempt to use these IDs at insert time + @Override + public void setPreferredListIds(Collection preferredListIds) + { + _preferredListIds = preferredListIds; + } + + @Override + public int getListId() + { + return _def.getListId(); + } + + public String getEntityId() + { + return _def.getEntityId(); + } + + @Override + public Container getContainer() + { + return ContainerManager.getForId(_def.getContainerId()); + } + + @Override + @Nullable + public Domain getDomain() + { + return getDomain(false); + } + + @Override + @Nullable + public Domain getDomain(boolean forUpdate) + { + if (_domain == null || (forUpdate && !_domain.isMutable())) // assure we have a mutable domain if needed, but don't ditch a mutable one because it may not have been saved yet + { + _domain = PropertyService.get().getDomain(_def.getDomainId(), forUpdate); + } + return _domain; + } + + @Override + public @NotNull Domain getDomainOrThrow() + { + return getDomainOrThrow(false); + } + + @Override + public @NotNull Domain getDomainOrThrow(boolean forUpdate) + { + var domain = getDomain(forUpdate); + if (domain == null) + throw new IllegalArgumentException("Could not find domain for list \"" + getName() + "\"."); + return domain; + } + + @Override + public String getName() + { + return _def.getName(); + } + + @Override + public String getKeyName() + { + return _def.getKeyName(); + } + + @Override + public void setKeyName(String name) + { + if (_def.getTitleColumn() != null && _def.getTitleColumn().equals(getKeyName())) + { + edit().setTitleColumn(name); + } + edit().setKeyName(name); + } + + @Override + public void setDescription(String description) + { + edit().setDescription(description); + } + + @Override + public KeyType getKeyType() + { + return KeyType.valueOf(_def.getKeyType()); + } + + @Override + public void setKeyType(KeyType type) + { + edit().setKeyType(type.toString()); + } + + @Override + public Category getCategory() + { + return _def.getCategory(); + } + + @Override + public void setCategory(Category category) + { + edit().setCategory(category); + } + + @Override + public int getCreatedBy() { return _def.getCreatedBy(); } + + @Override + public boolean getAllowDelete() + { + return _def.getAllowDelete(); + } + + @Override + public void setAllowDelete(boolean allowDelete) + { + edit().setAllowDelete(allowDelete); + } + + @Override + public boolean getAllowUpload() + { + return _def.getAllowUpload(); + } + + @Override + public void setAllowUpload(boolean allowUpload) + { + edit().setAllowUpload(allowUpload); + } + + @Override + public boolean getAllowExport() + { + return _def.getAllowExport(); + } + + @Override + public void setAllowExport(boolean allowExport) + { + edit().setAllowExport(allowExport); + } + + @Override + public boolean getEntireListIndex() + { + return _def.isEntireListIndex(); + } + + @Override + public void setEntireListIndex(boolean eachItemIndex) + { + edit().setEntireListIndex(eachItemIndex); + } + + @Override + public IndexSetting getEntireListIndexSetting() + { + return _def.getEntireListIndexSettingEnum(); + } + + @Override + public void setEntireListIndexSetting(IndexSetting setting) + { + edit().setEntireListIndexSettingEnum(setting); + } + + @Override + public @Nullable String getEntireListTitleTemplate() + { + return _def.getEntireListTitleTemplate(); + } + + @Override + public void setEntireListTitleTemplate(@Nullable String template) + { + edit().setEntireListTitleTemplate(template); + } + + @Override + public BodySetting getEntireListBodySetting() + { + return _def.getEntireListBodySettingEnum(); + } + + @Override + public void setEntireListBodySetting(BodySetting setting) + { + edit().setEntireListBodySettingEnum(setting); + } + + @Override + public String getEntireListBodyTemplate() + { + return _def.getEntireListBodyTemplate(); + } + + @Override + public void setEntireListBodyTemplate(String template) + { + edit().setEntireListBodyTemplate(template); + } + + @Override + public boolean getEachItemIndex() + { + return _def.isEachItemIndex(); + } + + @Override + public void setEachItemIndex(boolean eachItemIndex) + { + edit().setEachItemIndex(eachItemIndex); + } + + @Override + public @Nullable String getEachItemTitleTemplate() + { + return _def.getEachItemTitleTemplate(); + } + + @Override + public void setEachItemTitleTemplate(@Nullable String template) + { + edit().setEachItemTitleTemplate(template); + } + + @Override + public BodySetting getEachItemBodySetting() + { + return _def.getEachItemBodySettingEnum(); + } + + @Override + public void setEachItemBodySetting(BodySetting setting) + { + edit().setEachItemBodySettingEnum(setting); + } + + @Override + public String getEachItemBodyTemplate() + { + return _def.getEachItemBodyTemplate(); + } + + @Override + public void setEachItemBodyTemplate(String template) + { + edit().setEachItemBodyTemplate(template); + } + + @Override + public boolean getFileAttachmentIndex() + { + return _def.isFileAttachmentIndex(); + } + + @Override + public void setFileAttachmentIndex(boolean fileAttachmentIndex) + { + edit().setFileAttachmentIndex(fileAttachmentIndex); + } + + + @Override + public void save(User user) throws Exception + { + save(user, true, null, null); + } + + private static final ReentrantLock _saveLock = new ReentrantLockWithName(ListDefinitionImpl.class, "_saveLock"); + + @Override + public void save(User user, boolean ensureKey, @Nullable Map newRecordMap, @Nullable List calculatedFields) throws Exception + { + if (ensureKey) + { + assert getKeyName() != null : "Key not provided for List: " + getName(); + assert getKeyType() != null : "Invalid Key Type for List: " + getName(); + } + + try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction(_saveLock)) + { + if (ensureKey) + ensureKey(); + + Domain domain = getDomain(true); + + if (_new) + { + // The domain kind cannot lookup the list definition if the domain has not been saved + ((ListDomainKind) domain.getDomainKind()).setListDefinition(this); + + domain.save(user, newRecordMap, calculatedFields); + + _def.setDomainId(domain.getTypeId()); + ListDef inserted = ListManager.get().insert(user, _def, _preferredListIds); + _def = new ListDef.ListDefBuilder(inserted); + _new = false; + + ListManager.get().addAuditEvent(this, user, String.format("The list %s was created", _def.getName())); + } + else + { + ListDef updated = ListManager.get().update(user, _def); + _def = new ListDef.ListDefBuilder(updated); + ListManager.get().addAuditEvent(this, user, String.format("The definition of the list %s was modified", _def.getName())); + } + + transaction.commit(); + } + catch (RuntimeSQLException e) + { + if (RuntimeSQLException.isConstraintException(e.getSQLException())) + throw new ValidationException("The name '" + _def.getName() + "' is already in use."); + throw e; + } + + // Fetch the domain again to prime the cache, reducing potential for DB deadlocks + _domain = null; + getDomain(); + + ListManager.get().indexList(_def); + } + + private void ensureKey() + { + Domain domain = getDomain(); + for (DomainProperty dp : domain.getProperties()) + { + if (dp.getName().equalsIgnoreCase(getKeyName())) + return; + } + + DomainProperty prop = domain.addProperty(); + prop.setPropertyURI(DomainUtil.createUniquePropertyURI(domain.getTypeURI())); + prop.setName(getKeyName()); + prop.setType(PropertyService.get().getType(domain.getContainer(), getKeyType().getPropertyType().getXmlName())); + + domain.setPropertyIndex(prop, 0); + } + + @Override + public ListItem createListItem() + { + return new ListItemImpl(this); + } + + @Override + public ListItem getListItem(Object key, User user) + { + return getListItem(key, user, getContainer()); + } + + @Override + public ListItem getListItem(Object key, User user, Container c) + { + // Convert key value to the proper type, since PostgreSQL 8.3 requires that key parameter types match their column types. + Object typedKey = getKeyType().convertKey(key); + + return getListItem(new SimpleFilter(FieldKey.fromParts(getKeyName()), typedKey), user, c); + } + + @Override + public ListItem getListItemForEntityId(String entityId, User user) + { + return getListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer()); + } + + private ListItem getListItem(SimpleFilter filter, User user, Container c) + { + TableInfo tbl = new ListQuerySchema(user, c).getTable(getName()); + + if (null == tbl) + return null; + + Map row = null; + + try + { + row = new TableSelector(tbl, filter, null).getMap(); + } + catch (IllegalStateException e) + { + /* more than one row matches */ + } + + if (row == null) + return null; + + ListItm itm = new ListItm(); + + itm.setListId(getListId()); + itm.setEntityId(row.get("EntityId").toString()); + itm.setKey(row.get(getKeyName())); + + ListItemImpl impl = new ListItemImpl(this, itm); + for (DomainProperty prop : getDomainOrThrow().getProperties()) + { + impl.setProperty(prop, row.get(prop.getName())); + } + + return impl; + } + + public Container getListItemContainerForDownload(String entityId, User user, Class permissionClass) + { + Container c = getContainer(); + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("EntityId"), entityId); + // Use a relax CF to find the list items, permission will be validated later + ContainerFilter cf = ContainerFilter.Type.AllInProjectPlusShared.create(c, user); + TableInfo tbl = getTable(user, c, cf); + + if (null == tbl) + return null; + + Map row = null; + + try + { + row = new TableSelector(tbl, filter, null).getMap(); + } + catch (IllegalStateException e) + { + /* more than one row matches */ + } + + if (row == null) + return null; + + Container dataContainer = row.get("Container") != null ? ContainerManager.getForId(row.get("Container").toString()) : null; + if (dataContainer != null && dataContainer.hasPermission(user, permissionClass)) + return dataContainer; + + return null; + } + + @Override + public void delete(User user) throws DomainNotFoundException + { + delete(user, null); + } + + @Override + public void delete(User user, @Nullable String auditUserComment) throws DomainNotFoundException + { + TableInfo table = getTable(user); + QueryUpdateService qus = null; + + if (null != table) + qus = table.getUpdateService(); + + // In certain cases we may create a list that is not viable (i.e., one in which a table was never created because + // the metadata wasn't valid). Still allow deleting the list + try (DbScope.Transaction transaction = (table != null) ? table.getSchema().getScope().ensureTransaction() : + ExperimentService.get().ensureTransaction()) + { + // remove related attachments, discussions, and indices + ListManager.get().deleteIndexedList(this); + if (qus instanceof ListQueryUpdateService listQus) + listQus.deleteRelatedListData(user, getContainer()); + + // then delete the list itself + ListManager.get().deleteListDef(getContainer(), getListId()); + Domain domain = getDomainOrThrow(); + domain.delete(user, auditUserComment); + + ListManager.get().addAuditEvent(this, user, String.format("The list %s was deleted", _def.getName())); + + transaction.commit(); + } + + SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); + QueryService.get().fireQueryDeleted(user, getContainer(), null, schemaPath, Collections.singleton(getName())); + } + + + @Override + public int insertListItems(User user, Container container, List listItems) + { + BatchValidationException ve = new BatchValidationException(); + + List> rows = new ArrayList<>(); + + for (ListItem item : listItems) + { + Map row = new CaseInsensitiveHashMap<>(); + Map propertyMap = item.getProperties(); + + if (null != propertyMap) + { + for (String key : propertyMap.keySet()) + { + ObjectProperty prop = propertyMap.get(key); + if (null != prop) + { + row.put(prop.getName(), prop.getObjectValue()); + } + } + rows.add(row); + } + } + + MapLoader loader = new MapLoader(rows); + + // TODO: Find out the attachment directory? + return insertListItems(user, container, loader, ve, null, null, false, LookupResolutionType.primaryKey); + } + + + @Override + public int insertListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType) + { + return importListItems(user, container, loader, errors, attachmentDir, progress, supportAutoIncrementKey, lookupResolutionType, QueryUpdateService.InsertOption.INSERT); + } + + @Override + public int importListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType, QueryUpdateService.InsertOption insertOption) + { + ListQuerySchema schema = new ListQuerySchema(user, container); + TableInfo table = schema.getTable(_def.getName()); + if (null != table) + { + ListQueryUpdateService lqus = (ListQueryUpdateService) table.getUpdateService(); + if (null != lqus) + return lqus.insertUsingDataIterator(loader, user, container, errors, attachmentDir, progress, supportAutoIncrementKey, insertOption, lookupResolutionType); + } + return 0; + } + + + @Override + public String getDescription() + { + return _def.getDescription(); + } + + @Override + public String getTitleColumn() + { + return _def.getTitleColumn(); + } + + @Override + public void setTitleColumn(String titleColumn) + { + edit().setTitleColumn(titleColumn); + } + + @Override + public Date getModified() + { + return _def.getModified(); + } + + @Override + public void setModified(Date modified) + { + edit().setModified(modified); + } + + @Override + public Date getLastIndexed() + { + return _def.getLastIndexed(); + } + + @Override + public void setLastIndexed(Date modified) + { + edit().setLastIndexed(modified); + } + + @Override + @Nullable + public TableInfo getTable(User user) + { + return getTable(user, getContainer()); + } + + @Override + @Nullable + public TableInfo getTable(User user, Container c) + { + return getTable(user, c, null); + } + + @Override + @Nullable + public TableInfo getTable(User user, Container c, @Nullable ContainerFilter cf) + { + TableInfo table; + try + { + if (null != getDomain()) + { + // Go through the schema so we always get all the XML metadata applied + UserSchema schema = new ListQuerySchema(user, c); + table = schema.getTable(getName(), cf, true, false); + } + else + { + table = null; + LOG.warn("Failed to construct list table because domain not found"); + } + } + catch (IllegalStateException e) + { + /* Return a null table -- configuration failed */ + table = null; + LOG.warn("Failed to construct list table", e); + } + + return table; + } + + @Override + public TableInfo getTableForInsert(User user, Container c) + { + return getTable(user, c, QueryService.get().getContainerFilterForLookups(c, user)); + } + + @Override + public ActionURL urlImport(Container c) + { + return urlForName(ListController.UploadListItemsAction.class, c); + } + + @Override + public ActionURL urlShowDefinition() + { + return urlFor(ListController.EditListDefinitionAction.class, getContainer()); + } + + @Override + public ActionURL urlShowData(Container c) + { + return urlForName(ListController.GridAction.class, c); + } + + @Override + public ActionURL urlUpdate(User user, Container container, @Nullable Object pk, @Nullable URLHelper returnAndCancelUrl) + { + ActionURL url = QueryService.get().urlFor(user, container, QueryAction.updateQueryRow, ListQuerySchema.NAME, getName()); + + // Can be null if caller will be filling in pk (e.g., grid edit column) + if (null != pk) + url.addParameter("pk", pk.toString()); + + if (returnAndCancelUrl != null) + { + url.addCancelURL(returnAndCancelUrl); + url.addReturnUrl(returnAndCancelUrl); + } + + return url; + } + + @Override + public ActionURL urlDetails(@Nullable Object pk) + { + return urlDetails(pk, getContainer()); + } + + @Override + public ActionURL urlDetails(@Nullable Object pk, Container c) + { + ActionURL url = urlForName(ListController.DetailsAction.class, c); + // Can be null if caller will be filling in pk (e.g., grid edit column) + + if (null != pk) + url.addParameter("pk", pk.toString()); + + return url; + } + + @Override + public ActionURL urlShowHistory(Container c) + { + return urlFor(ListController.HistoryAction.class, c); + } + + @Override + public ActionURL urlShowData() + { + return urlShowData(getContainer()); + } + + @Override + public ActionURL urlFor(Class actionClass) + { + return urlFor(actionClass, getContainer()); + } + + @Override + public ActionURL urlFor(Class actionClass, Container c) + { + ActionURL ret = new ActionURL(actionClass, c); + ret.addParameter("listId", getListId()); + return ret; + } + + private ActionURL urlForName(Class actionClass, Container c) + { + ActionURL ret = new ActionURL(actionClass, c); + ret.addParameter("name", getName()); + return ret; + } + + private ListDef.ListDefBuilder edit() + { + return _def; + } + + @Override + public Collection getDependents(User user) + { + SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); + return QueryService.get().getQueryDependents(user, getContainer(), null, schemaPath, Collections.singleton(getName())); + } + + @Override + public String toString() + { + return getName() + ", id: " + getListId(); + } + + @Override + public int compareTo(ListDefinition l) + { + return getName().compareToIgnoreCase(l.getName()); + } +} diff --git a/list/src/org/labkey/list/model/ListManagerSchema.java b/list/src/org/labkey/list/model/ListManagerSchema.java index ff16718f3d2..55628fb0254 100644 --- a/list/src/org/labkey/list/model/ListManagerSchema.java +++ b/list/src/org/labkey/list/model/ListManagerSchema.java @@ -1,237 +1,263 @@ -/* - * Copyright (c) 2014-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.Sort; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.module.Module; -import org.labkey.api.query.DefaultSchema; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QuerySchema; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.UserSchema; -import org.labkey.api.security.User; -import org.labkey.api.util.ButtonBuilder; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.ViewContext; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.list.controllers.ListController; -import org.springframework.validation.BindException; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; - -public class ListManagerSchema extends UserSchema -{ - private static final Set TABLE_NAMES; - public static final String LIST_MANAGER = "ListManager"; - public static final String SCHEMA_NAME = "ListManager"; - - static - { - Set names = new TreeSet<>(); - names.add(LIST_MANAGER); - TABLE_NAMES = Collections.unmodifiableSet(names); - } - - public ListManagerSchema(User user, Container container) - { - super(SCHEMA_NAME, "Contains list of lists", user, container, ExperimentService.get().getSchema()); - _hidden = true; - } - - public static void register(Module module) - { - DefaultSchema.registerProvider(SCHEMA_NAME, new DefaultSchema.SchemaProvider(module) - { - @Override - public boolean isAvailable(DefaultSchema schema, Module module) - { - return true; - } - - @Override - public QuerySchema createSchema(DefaultSchema schema, Module module) - { - return new ListManagerSchema(schema.getUser(), schema.getContainer()); - } - }); - } - - @Nullable - @Override - public TableInfo createTable(String name, ContainerFilter cf) - { - if (LIST_MANAGER.equalsIgnoreCase(name)) - { - TableInfo dbTable = getDbSchema().getTable("list"); - ListManagerTable table = new ListManagerTable(this, dbTable, cf); - table.setName("Available Lists"); - return table; - } - else - { - return null; - } - } - - @Override - protected QuerySettings createQuerySettings(String dataRegionName, String queryName, String viewName) - { - QuerySettings settings = super.createQuerySettings(dataRegionName, queryName, viewName); - if (LIST_MANAGER.equalsIgnoreCase(queryName)) - { - settings.setBaseSort(new Sort("Name")); - } - return settings; - } - - @Override - @NotNull - public QueryView createView(ViewContext context, @NotNull QuerySettings settings, BindException errors) - { - if (LIST_MANAGER.equalsIgnoreCase(settings.getQueryName())) - { - QueryView qv = new QueryView(this, settings, errors) - { - final QuerySettings s = getSettings(); - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - bar.add(super.createViewButton(getViewItemFilter())); - populateChartsReports(bar); - bar.add(createDeleteButton()); - List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); - bar.add(super.createExportButton(recordSelectorColumns)); - bar.add(createCreateNewListButton()); - bar.add(createImportListArchiveButton()); - bar.add(createExportArchiveButton()); - } - - private ActionButton createCreateNewListButton() - { - ActionURL urlCreate = new ActionURL(ListController.EditListDefinitionAction.class, getContainer()); - ActionButton btnCreate = new ActionButton("Create New List", urlCreate); - btnCreate.setDisplayPermission(DesignListPermission.class); - return btnCreate; - } - - private ButtonBuilder.Button createImportListArchiveButton() - { - ActionURL urlImport = new ActionURL(ListController.ImportListArchiveAction.class, getContainer()); - urlImport.addReturnUrl(getReturnUrl()); - ButtonBuilder.Button btnImport = new ButtonBuilder("Import List Archive") - .href(urlImport) - .build(); - btnImport.setDisplayPermission(DesignListPermission.class); - return btnImport; - } - - @Override - public ActionButton createDeleteButton() - { - ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); - urlDelete.addReturnUrl(getReturnUrl()); - ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); - btnDelete.setIconCls("trash"); - btnDelete.setActionType(ActionButton.Action.GET); - btnDelete.setDisplayPermission(DesignListPermission.class); - btnDelete.setRequiresSelection(true); - return btnDelete; - } - - private ActionButton createExportArchiveButton() - { - ActionURL urlExport = new ActionURL(ListController.ExportListArchiveAction.class, getContainer()); - ActionButton btnExport = new ActionButton(urlExport, "Export List Archive"); - btnExport.setRequiresSelection(true); - btnExport.setActionType(ActionButton.Action.POST); - btnExport.setDisplayPermission(DesignListPermission.class); - return btnExport; - } - - @Override - protected void addDetailsAndUpdateColumns(List ret, TableInfo table) - { - if (getContainer().hasPermission(getUser(), DesignListPermission.class)) - { - ret.add(new SimpleDisplayColumn() - { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); - ActionURL designUrl = new ActionURL(ListController.EditListDefinitionAction.class, c); - designUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); - out.write(LinkBuilder.labkeyLink("Design", designUrl)); - } - }); - } - - if (AuditLogService.get().isViewable()) - { - ret.add(new SimpleDisplayColumn() - { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); - ActionURL historyUrl = new ActionURL(ListController.HistoryAction.class, c); - historyUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); - out.write(LinkBuilder.labkeyLink("View History", historyUrl)); - } - }); - } - } - }; - - qv.setAllowableContainerFilterTypes( - ContainerFilter.Type.Current, - ContainerFilter.Type.CurrentAndSubfolders, - ContainerFilter.Type.CurrentPlusProjectAndShared, - ContainerFilter.Type.AllFolders - ); - - return qv; - } - - return super.createView(context, settings, errors); - } - @Override - public Set getTableNames() - { - return TABLE_NAMES; - } -} +/* + * Copyright (c) 2014-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.Container; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.Sort; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.module.Module; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.UserSchema; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.util.ButtonBuilder; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.ViewContext; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.list.controllers.ListController; +import org.springframework.validation.BindException; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +public class ListManagerSchema extends UserSchema +{ + private static final Set TABLE_NAMES; + public static final String LIST_MANAGER = "ListManager"; + public static final String SCHEMA_NAME = "ListManager"; + + static + { + Set names = new TreeSet<>(); + names.add(LIST_MANAGER); + TABLE_NAMES = Collections.unmodifiableSet(names); + } + + public ListManagerSchema(User user, Container container) + { + super(SCHEMA_NAME, "Contains list of lists", user, container, ExperimentService.get().getSchema()); + _hidden = true; + } + + public static void register(Module module) + { + DefaultSchema.registerProvider(SCHEMA_NAME, new DefaultSchema.SchemaProvider(module) + { + @Override + public boolean isAvailable(DefaultSchema schema, Module module) + { + return true; + } + + @Override + public QuerySchema createSchema(DefaultSchema schema, Module module) + { + return new ListManagerSchema(schema.getUser(), schema.getContainer()); + } + }); + } + + @Nullable + @Override + public TableInfo createTable(String name, ContainerFilter cf) + { + if (LIST_MANAGER.equalsIgnoreCase(name)) + { + TableInfo dbTable = getDbSchema().getTable("list"); + ListManagerTable table = new ListManagerTable(this, dbTable, cf); + table.setName("Available Lists"); + return table; + } + else + { + return null; + } + } + + @Override + protected QuerySettings createQuerySettings(String dataRegionName, String queryName, String viewName) + { + QuerySettings settings = super.createQuerySettings(dataRegionName, queryName, viewName); + if (LIST_MANAGER.equalsIgnoreCase(queryName)) + { + settings.setBaseSort(new Sort("Name")); + } + return settings; + } + + @Override + @NotNull + public QueryView createView(ViewContext context, @NotNull QuerySettings settings, BindException errors) + { + if (LIST_MANAGER.equalsIgnoreCase(settings.getQueryName())) + { + QueryView qv = new QueryView(this, settings, errors) + { + final QuerySettings s = getSettings(); + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + bar.add(super.createViewButton(getViewItemFilter())); + populateChartsReports(bar); + bar.add(createDeleteButton()); + List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); + bar.add(super.createExportButton(recordSelectorColumns)); + bar.add(createCreateNewListButton()); + bar.add(createImportListArchiveButton()); + bar.add(createExportArchiveButton()); + } + + private ActionButton createCreateNewListButton() + { + ActionURL urlCreate = new ActionURL(ListController.EditListDefinitionAction.class, getContainer()); + ActionButton btnCreate = new ActionButton("Create New List", urlCreate); + btnCreate.setDisplayPermission(DesignListPermission.class); + return btnCreate; + } + + private ButtonBuilder.Button createImportListArchiveButton() + { + ActionURL urlImport = new ActionURL(ListController.ImportListArchiveAction.class, getContainer()); + urlImport.addReturnUrl(getReturnUrl()); + ButtonBuilder.Button btnImport = new ButtonBuilder("Import List Archive") + .href(urlImport) + .build(); + btnImport.setDisplayPermission(DesignListPermission.class); + return btnImport; + } + + @Override + public ActionButton createDeleteButton() + { + if (!getContainer().hasPermission(getUser(), DesignListPermission.class)) + return null; + + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + { + ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); + urlDelete.addReturnUrl(getReturnUrl()); + ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); + btnDelete.setIconCls("trash"); + btnDelete.setActionType(ActionButton.Action.GET); + btnDelete.setRequiresSelection(true); + return btnDelete; + } + + MenuButton menuDelete = new MenuButton("Delete"); + menuDelete.setIconCls("trash"); + menuDelete.setDisplayPermission(DesignListPermission.class); + menuDelete.setRequiresSelection(true); + + ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); + urlDelete.addReturnUrl(getReturnUrl()); + menuDelete.addMenuItem("Delete List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + + urlDelete.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); + + + ActionURL urlTruncate = new ActionURL(ListController.TruncateListDataAction.class, getContainer()); + urlTruncate.addReturnUrl(getReturnUrl()); + menuDelete.addMenuItem("Delete All Data from List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + + urlTruncate.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); + + return menuDelete; + } + + private ActionButton createExportArchiveButton() + { + ActionURL urlExport = new ActionURL(ListController.ExportListArchiveAction.class, getContainer()); + ActionButton btnExport = new ActionButton(urlExport, "Export List Archive"); + btnExport.setRequiresSelection(true); + btnExport.setActionType(ActionButton.Action.POST); + btnExport.setDisplayPermission(DesignListPermission.class); + return btnExport; + } + + @Override + protected void addDetailsAndUpdateColumns(List ret, TableInfo table) + { + if (getContainer().hasPermission(getUser(), DesignListPermission.class)) + { + ret.add(new SimpleDisplayColumn() + { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); + ActionURL designUrl = new ActionURL(ListController.EditListDefinitionAction.class, c); + designUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); + out.write(LinkBuilder.labkeyLink("Design", designUrl)); + } + }); + } + + if (AuditLogService.get().isViewable()) + { + ret.add(new SimpleDisplayColumn() + { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); + ActionURL historyUrl = new ActionURL(ListController.HistoryAction.class, c); + historyUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); + out.write(LinkBuilder.labkeyLink("View History", historyUrl)); + } + }); + } + } + }; + + qv.setAllowableContainerFilterTypes( + ContainerFilter.Type.Current, + ContainerFilter.Type.CurrentAndSubfolders, + ContainerFilter.Type.CurrentPlusProjectAndShared, + ContainerFilter.Type.AllFolders + ); + + return qv; + } + + return super.createView(context, settings, errors); + } + @Override + public Set getTableNames() + { + return TABLE_NAMES; + } +} diff --git a/list/src/org/labkey/list/model/ListQueryUpdateService.java b/list/src/org/labkey/list/model/ListQueryUpdateService.java index 8110e0c2b13..e3867f1893a 100644 --- a/list/src/org/labkey/list/model/ListQueryUpdateService.java +++ b/list/src/org/labkey/list/model/ListQueryUpdateService.java @@ -1,885 +1,885 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentParentFactory; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.LookupResolutionType; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.Selector.ForEachBatchBlock; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.DataIteratorBuilder; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; -import org.labkey.api.dataiterator.ImportProgress; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.IPropertyValidator; -import org.labkey.api.exp.property.ValidatorContext; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.lists.permissions.ManagePicklistsPermission; -import org.labkey.api.query.AbstractQueryUpdateService; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.DefaultQueryUpdateService; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.PropertyValidationError; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.ValidationError; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.security.ElevatedUser; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.MoveEntitiesPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.security.roles.EditorRole; -import org.labkey.api.usageMetrics.SimpleMetricsService; -import org.labkey.api.util.GUID; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.StringUtilsLabKey; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.writer.VirtualFile; -import org.labkey.list.view.ListItemAttachmentParent; - -import java.io.IOException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.labkey.api.util.IntegerUtils.isIntegral; - -/** - * Implementation of QueryUpdateService for Lists - */ -public class ListQueryUpdateService extends DefaultQueryUpdateService -{ - private final ListDefinitionImpl _list; - private static final String ID = "entityId"; - - public ListQueryUpdateService(ListTable queryTable, TableInfo dbTable, @NotNull ListDefinition list) - { - super(queryTable, dbTable, createMVMapping(queryTable.getList().getDomain())); - _list = (ListDefinitionImpl) list; - } - - @Override - public void configureDataIteratorContext(DataIteratorContext context) - { - if (context.getInsertOption().batch) - { - context.setMaxRowErrors(100); - context.setFailFast(false); - } - - context.putConfigParameter(ConfigParameters.TrimStringRight, Boolean.TRUE); - } - - @Override - protected @Nullable AttachmentParentFactory getAttachmentParentFactory() - { - return new ListItemAttachmentParentFactory(); - } - - @Override - protected Map getRow(User user, Container container, Map listRow) throws InvalidKeyException - { - Map ret = null; - - if (null != listRow) - { - SimpleFilter keyFilter = getKeyFilter(listRow); - - if (null != keyFilter) - { - TableInfo queryTable = getQueryTable(); - Map raw = new TableSelector(queryTable, keyFilter, null).getMap(); - - if (null != raw && !raw.isEmpty()) - { - ret = new CaseInsensitiveHashMap<>(); - - // EntityId - ret.put("EntityId", raw.get("entityid")); - - for (DomainProperty prop : _list.getDomainOrThrow().getProperties()) - { - String propName = prop.getName(); - ColumnInfo column = queryTable.getColumn(propName); - Object value = column.getValue(raw); - if (value != null) - ret.put(propName, value); - } - } - } - } - - return ret; - } - - - @Override - public List> insertRows(User user, Container container, List> rows, BatchValidationException errors, - @Nullable Map configParameters, Map extraScriptContext) - { - for (Map row : rows) - { - aliasColumns(getColumnMapping(), row); - } - - DataIteratorContext context = getDataIteratorContext(errors, InsertOption.INSERT, configParameters); - recordDataIteratorUsed(configParameters); - List> result = this._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - - if (null != result) - { - ListManager mgr = ListManager.get(); - - for (Map row : result) - { - if (null != row.get(ID)) - { - // Audit each row - String entityId = (String) row.get(ID); - String newRecord = mgr.formatAuditItem(_list, user, row); - - mgr.addAuditEvent(_list, user, container, "A new list record was inserted", entityId, null, newRecord); - } - } - - if (!result.isEmpty() && !errors.hasErrors()) - mgr.indexList(_list); - } - - return result; - } - - private User getListUser(User user, Container container) - { - if (_list.isPicklist() && container.hasPermission(user, ManagePicklistsPermission.class)) - { - // if the list is a picklist and you have permission to manage picklists, that equates - // to having editor permission. - return ElevatedUser.ensureContextualRoles(container, user, Pair.of(DeletePermission.class, EditorRole.class)); - } - return user; - } - - @Override - protected @Nullable List> _insertRowsUsingDIB(User user, Container container, List> rows, DataIteratorContext context, @Nullable Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - return super._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - } - - @Override - protected @Nullable List> _updateRowsUsingDIB(User user, Container container, List> rows, - DataIteratorContext context, @Nullable Map extraScriptContext) - { - if (!hasPermission(user, UpdatePermission.class)) - throw new UnauthorizedException("You do not have permission to update data in this table."); - - return super._updateRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - } - - public int insertUsingDataIterator(DataLoader loader, User user, Container container, BatchValidationException errors, @Nullable VirtualFile attachmentDir, - @Nullable ImportProgress progress, boolean supportAutoIncrementKey, InsertOption insertOption, LookupResolutionType lookupResolutionType) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - User updatedUser = getListUser(user, container); - DataIteratorContext context = new DataIteratorContext(errors); - context.setFailFast(false); - context.setInsertOption(insertOption); // this method is used by ListImporter and BackgroundListImporter - context.setSupportAutoIncrementKey(supportAutoIncrementKey); - context.setLookupResolutionType(lookupResolutionType); - setAttachmentDirectory(attachmentDir); - TableInfo ti = _list.getTable(updatedUser); - - if (null != ti) - { - try (DbScope.Transaction transaction = ti.getSchema().getScope().ensureTransaction()) - { - int imported = _importRowsUsingDIB(updatedUser, container, loader, null, context, new HashMap<>()); - - if (!errors.hasErrors()) - { - //Make entry to audit log if anything was inserted - if (imported > 0) - ListManager.get().addAuditEvent(_list, updatedUser, "Bulk " + (insertOption.updateOnly ? "updated " : (insertOption.mergeRows ? "imported " : "inserted ")) + imported + " rows to list."); - - transaction.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); - transaction.commit(); - - return imported; - } - - return 0; - } - } - - return 0; - } - - - @Override - public int mergeRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, - @Nullable Map configParameters, Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to update data in this table."); - - recordDataIteratorUsed(configParameters); - return _importRowsUsingDIB(getListUser(user, container), container, rows, null, getDataIteratorContext(errors, InsertOption.MERGE, configParameters), extraScriptContext); - } - - - @Override - public int importRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, - Map configParameters, Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - recordDataIteratorUsed(configParameters); - DataIteratorContext context = getDataIteratorContext(errors, InsertOption.IMPORT, configParameters); - int count = _importRowsUsingDIB(getListUser(user, container), container, rows, null, context, extraScriptContext); - if (count > 0 && !errors.hasErrors()) - ListManager.get().indexList(_list); - return count; - } - - @Override - public List> updateRows(User user, Container container, List> rows, List> oldKeys, - BatchValidationException errors, @Nullable Map configParameters, Map extraScriptContext) - throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to update data into this table."); - - List> result = super.updateRows(getListUser(user, container), container, rows, oldKeys, errors, configParameters, extraScriptContext); - if (!result.isEmpty()) - ListManager.get().indexList(_list); - return result; - } - - @Override - protected Map updateRow(User user, Container container, Map row, @NotNull Map oldRow, @Nullable Map configParameters) - throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException - { - // TODO: Check for equivalency so that attachments can be deleted etc. - - Map dps = new HashMap<>(); - for (DomainProperty dp : _list.getDomainOrThrow().getProperties()) - { - dps.put(dp.getPropertyURI(), dp); - } - - ValidatorContext validatorCache = new ValidatorContext(container, user); - - ListItm itm = new ListItm(); - itm.setEntityId((String) oldRow.get(ID)); - itm.setListId(_list.getListId()); - itm.setKey(oldRow.get(_list.getKeyName())); - - ListItem item = new ListItemImpl(_list, itm); - - if (item.getProperties() != null) - { - List errors = new ArrayList<>(); - for (Map.Entry entry : dps.entrySet()) - { - Object value = row.get(entry.getValue().getName()); - validateProperty(entry.getValue(), value, row, errors, validatorCache); - } - - if (!errors.isEmpty()) - throw new ValidationException(errors); - } - - // MVIndicators - Map rowCopy = new CaseInsensitiveHashMap<>(); - ArrayList modifiedAttachmentColumns = new ArrayList<>(); - ArrayList attachmentFiles = new ArrayList<>(); - - TableInfo qt = getQueryTable(); - for (Map.Entry r : row.entrySet()) - { - ColumnInfo column = qt.getColumn(FieldKey.fromParts(r.getKey())); - rowCopy.put(r.getKey(), r.getValue()); - - // 22747: Attachment columns - if (null != column) - { - DomainProperty dp = _list.getDomainOrThrow().getPropertyByURI(column.getPropertyURI()); - if (null != dp && isAttachmentProperty(dp)) - { - modifiedAttachmentColumns.add(column); - - // setup any new attachments - if (r.getValue() instanceof AttachmentFile file) - { - if (null != file.getFilename()) - attachmentFiles.add(file); - } - else if (r.getValue() != null && !StringUtils.isEmpty(String.valueOf(r.getValue()))) - { - throw new ValidationException("Cannot upload '" + r.getValue() + "' to Attachment type field '" + r.getKey() + "'."); - } - } - } - } - - // Attempt to include key from oldRow if not found in row (As stated in the QUS Interface) - Object newRowKey = getField(rowCopy, _list.getKeyName()); - Object oldRowKey = getField(oldRow, _list.getKeyName()); - - if (null == newRowKey && null != oldRowKey) - rowCopy.put(_list.getKeyName(), oldRowKey); - - Map result = super.updateRow(getListUser(user, container), container, rowCopy, oldRow, true, false); - - if (null != result) - { - result = getRow(user, container, result); - - if (null != result && null != result.get(ID)) - { - ListManager mgr = ListManager.get(); - String entityId = (String) result.get(ID); - - try - { - // Remove prior attachment -- only includes columns which are modified in this update - for (ColumnInfo col : modifiedAttachmentColumns) - { - Object value = oldRow.get(col.getName()); - if (null != value) - { - AttachmentService.get().deleteAttachment(new ListItemAttachmentParent(entityId, _list.getContainer()), value.toString(), user); - } - } - - // Update attachments - if (!attachmentFiles.isEmpty()) - AttachmentService.get().addAttachments(new ListItemAttachmentParent(entityId, _list.getContainer()), attachmentFiles, user); - } - catch (AttachmentService.DuplicateFilenameException | AttachmentService.FileTooLargeException e) - { - // issues 21503, 28633: turn these into a validation exception to get a nicer error - throw new ValidationException(e.getMessage()); - } - catch (IOException e) - { - throw UnexpectedException.wrap(e); - } - finally - { - for (AttachmentFile attachmentFile : attachmentFiles) - { - try { attachmentFile.closeInputStream(); } catch (IOException ignored) {} - } - } - - String oldRecord = mgr.formatAuditItem(_list, user, oldRow); - String newRecord = mgr.formatAuditItem(_list, user, result); - - // Audit - mgr.addAuditEvent(_list, user, container, "An existing list record was modified", entityId, oldRecord, newRecord); - } - } - - return result; - } - - // TODO: Consolidate with ColumnValidator and OntologyManager.validateProperty() - private boolean validateProperty(DomainProperty prop, Object value, Map newRow, List errors, ValidatorContext validatorCache) - { - //check for isRequired - if (prop.isRequired()) - { - // for mv indicator columns either an indicator or a field value is sufficient - boolean hasMvIndicator = prop.isMvEnabled() && (value instanceof ObjectProperty && ((ObjectProperty)value).getMvIndicator() != null); - if (!hasMvIndicator && (null == value || (value instanceof ObjectProperty && ((ObjectProperty)value).value() == null))) - { - if (newRow.containsKey(prop.getName()) && newRow.get(prop.getName()) == null) - { - errors.add(new PropertyValidationError("The field '" + prop.getName() + "' is required.", prop.getName())); - return false; - } - } - } - - if (null != value) - { - for (IPropertyValidator validator : prop.getValidators()) - { - if (!validator.validate(prop.getPropertyDescriptor(), value, errors, validatorCache)) - return false; - } - } - - return true; - } - - private record ListRecord(Object key, String entityId) { } - - @Override - public Map moveRows( - User _user, - Container container, - Container targetContainer, - List> rows, - BatchValidationException errors, - @Nullable Map configParameters, - @Nullable Map extraScriptContext - ) throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException - { - // Ensure the list is in scope for the target container - if (null == ListService.get().getList(targetContainer, _list.getName(), true)) - { - errors.addRowError(new ValidationException(String.format("List '%s' is not accessible from folder %s.", _list.getName(), targetContainer.getPath()))); - throw errors; - } - - User user = getListUser(_user, container); - Map> containerRows = getListRowsForMoveRows(container, user, targetContainer, rows, errors); - if (errors.hasErrors()) - throw errors; - - int fileAttachmentsMovedCount = 0; - int listAuditEventsCreatedCount = 0; - int listAuditEventsMovedCount = 0; - int listRecordsCount = 0; - int queryAuditEventsMovedCount = 0; - - if (containerRows.isEmpty()) - return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); - - AuditBehaviorType auditBehavior = configParameters != null ? (AuditBehaviorType) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior) : null; - String auditUserComment = configParameters != null ? (String) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment) : null; - boolean hasAttachmentProperties = _list.getDomainOrThrow() - .getProperties() - .stream() - .anyMatch(prop -> PropertyType.ATTACHMENT.equals(prop.getPropertyType())); - - ListAuditProvider listAuditProvider = new ListAuditProvider(); - final int BATCH_SIZE = 5_000; - boolean isAuditEnabled = auditBehavior != null && AuditBehaviorType.NONE != auditBehavior; - - try (DbScope.Transaction tx = getDbTable().getSchema().getScope().ensureTransaction()) - { - if (isAuditEnabled && tx.getAuditEvent() == null) - { - TransactionAuditProvider.TransactionAuditEvent auditEvent = createTransactionAuditEvent(targetContainer, QueryService.AuditAction.UPDATE); - auditEvent.updateCommentRowCount(containerRows.values().stream().mapToInt(List::size).sum()); - AbstractQueryUpdateService.addTransactionAuditEvent(tx, user, auditEvent); - } - - List listAuditEvents = new ArrayList<>(); - - for (GUID containerId : containerRows.keySet()) - { - Container sourceContainer = ContainerManager.getForId(containerId); - if (sourceContainer == null) - throw new InvalidKeyException("Container '" + containerId + "' does not exist."); - - if (!sourceContainer.hasPermission(user, MoveEntitiesPermission.class)) - throw new UnauthorizedException("You do not have permission to move list records out of '" + sourceContainer.getName() + "'."); - - TableInfo listTable = _list.getTable(user, sourceContainer); - if (listTable == null) - throw new QueryUpdateServiceException(String.format("Failed to retrieve table for list '%s' in folder %s.", _list.getName(), sourceContainer.getPath())); - - List records = containerRows.get(containerId); - int numRecords = records.size(); - - for (int start = 0; start < numRecords; start += BATCH_SIZE) - { - int end = Math.min(start + BATCH_SIZE, numRecords); - List batch = records.subList(start, end); - List rowPks = batch.stream().map(ListRecord::key).toList(); - - // Before trigger per batch - Map extraContext = Map.of("targetContainer", targetContainer, "keys", rowPks); - listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, true, errors, extraContext); - if (errors.hasErrors()) - throw errors; - - listRecordsCount += Table.updateContainer(getDbTable(), _list.getKeyName(), rowPks, targetContainer, user, true); - if (errors.hasErrors()) - throw errors; - - if (hasAttachmentProperties) - { - fileAttachmentsMovedCount += moveAttachments(user, sourceContainer, targetContainer, batch, errors); - if (errors.hasErrors()) - throw errors; - } - - queryAuditEventsMovedCount += QueryService.get().moveAuditEvents(targetContainer, rowPks, ListQuerySchema.NAME, _list.getName()); - listAuditEventsMovedCount += listAuditProvider.moveEvents(targetContainer, batch.stream().map(ListRecord::entityId).toList()); - - // Detailed audit events per row - if (AuditBehaviorType.DETAILED == listTable.getEffectiveAuditBehavior(auditBehavior)) - listAuditEventsCreatedCount += addDetailedMoveAuditEvents(user, sourceContainer, targetContainer, batch); - - // After trigger per batch - listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, false, errors, extraContext); - if (errors.hasErrors()) - throw errors; - } - - // Create a summary audit event for the source container - if (isAuditEnabled) - { - String comment = String.format("Moved %s to %s", StringUtilsLabKey.pluralize(numRecords, "row"), targetContainer.getPath()); - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(sourceContainer, comment, _list); - event.setUserComment(auditUserComment); - listAuditEvents.add(event); - } - - // Create a summary audit event for the target container - if (isAuditEnabled) - { - String comment = String.format("Moved %s from %s", StringUtilsLabKey.pluralize(numRecords, "row"), sourceContainer.getPath()); - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, comment, _list); - event.setUserComment(auditUserComment); - listAuditEvents.add(event); - } - } - - if (!listAuditEvents.isEmpty()) - { - AuditLogService.get().addEvents(user, listAuditEvents, true); - listAuditEventsCreatedCount += listAuditEvents.size(); - } - - tx.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); - - tx.commit(); - - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "moveEntities", "list"); - } - - return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); - } - - private Map moveRowsCounts(int fileAttachmentsMovedCount, int listAuditEventsCreated, int listAuditEventsMoved, int listRecords, int queryAuditEventsMoved) - { - return Map.of( - "fileAttachmentsMoved", fileAttachmentsMovedCount, - "listAuditEventsCreated", listAuditEventsCreated, - "listAuditEventsMoved", listAuditEventsMoved, - "listRecords", listRecords, - "queryAuditEventsMoved", queryAuditEventsMoved - ); - } - - private Map> getListRowsForMoveRows( - Container container, - User user, - Container targetContainer, - List> rows, - BatchValidationException errors - ) throws QueryUpdateServiceException - { - if (rows.isEmpty()) - return Collections.emptyMap(); - - String keyName = _list.getKeyName(); - List keys = new ArrayList<>(); - for (var row : rows) - { - Object key = getField(row, keyName); - if (key == null) - { - errors.addRowError(new ValidationException("Key field value required for moving list rows.")); - return Collections.emptyMap(); - } - - keys.add(getKeyFilterValue(key)); - } - - SimpleFilter filter = new SimpleFilter(); - FieldKey fieldKey = FieldKey.fromParts(keyName); - filter.addInClause(fieldKey, keys); - filter.addCondition(FieldKey.fromParts("Container"), targetContainer.getId(), CompareType.NEQ); - - // Request all rows without a container filter so that rows are more easily resolved across the list scope. - // Read permissions are subsequently checked upon loading a row. - TableInfo table = _list.getTable(user, container, ContainerFilter.getUnsafeEverythingFilter()); - if (table == null) - throw new QueryUpdateServiceException(String.format("Failed to resolve table for list %s in %s", _list.getName(), container.getPath())); - - Map> containerRows = new HashMap<>(); - try (var result = new TableSelector(table, PageFlowUtil.set(keyName, "Container", "EntityId"), filter, null).getResults()) - { - while (result.next()) - { - GUID containerId = new GUID(result.getString("Container")); - if (!containerRows.containsKey(containerId)) - { - var c = ContainerManager.getForId(containerId); - if (c == null) - throw new QueryUpdateServiceException(String.format("Failed to resolve container for row in list %s in %s.", _list.getName(), container.getPath())); - else if (!c.hasPermission(user, ReadPermission.class)) - throw new UnauthorizedException("You do not have permission to read list rows in all source containers."); - } - - containerRows.computeIfAbsent(containerId, k -> new ArrayList<>()); - containerRows.get(containerId).add(new ListRecord(result.getObject(fieldKey), result.getString("EntityId"))); - } - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - - return containerRows; - } - - private int moveAttachments(User user, Container sourceContainer, Container targetContainer, List records, BatchValidationException errors) - { - List parents = new ArrayList<>(); - for (ListRecord record : records) - parents.add(new ListItemAttachmentParent(record.entityId, sourceContainer)); - - int count = 0; - try - { - count = AttachmentService.get().moveAttachments(targetContainer, parents, user); - } - catch (IOException e) - { - errors.addRowError(new ValidationException("Failed to move attachments when moving list rows. Error: " + e.getMessage())); - } - - return count; - } - - private int addDetailedMoveAuditEvents(User user, Container sourceContainer, Container targetContainer, List records) - { - List auditEvents = new ArrayList<>(records.size()); - String keyName = _list.getKeyName(); - String sourcePath = sourceContainer.getPath(); - String targetPath = targetContainer.getPath(); - - for (ListRecord record : records) - { - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, "An existing list record was moved", _list); - event.setListItemEntityId(record.entityId); - event.setOldRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", sourcePath, keyName, record.key.toString()))); - event.setNewRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", targetPath, keyName, record.key.toString()))); - auditEvents.add(event); - } - - AuditLogService.get().addEvents(user, auditEvents, true); - - return auditEvents.size(); - } - - @Override - protected Map deleteRow(User user, Container container, Map oldRowMap) throws InvalidKeyException, QueryUpdateServiceException, SQLException - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to delete data from this table."); - - Map result = super.deleteRow(getListUser(user, container), container, oldRowMap); - - if (null != result) - { - String entityId = (String) result.get(ID); - - if (null != entityId) - { - ListManager mgr = ListManager.get(); - String deletedRecord = mgr.formatAuditItem(_list, user, result); - - // Audit - mgr.addAuditEvent(_list, user, container, "An existing list record was deleted", entityId, deletedRecord, null); - - // Remove attachments - if (hasAttachmentProperties()) - AttachmentService.get().deleteAttachments(new ListItemAttachmentParent(entityId, container)); - - // Clean up Search indexer - if (!result.isEmpty()) - mgr.deleteItemIndex(_list, entityId); - } - } - - return result; - } - - - // Deletes attachments & discussions, and removes list documents from full-text search index. - public void deleteRelatedListData(final User user, final Container container) - { - // Unindex all item docs and the entire list doc - ListManager.get().deleteIndexedList(_list); - - // Delete attachments and discussions associated with a list in batches of 1,000 - new TableSelector(getDbTable(), Collections.singleton("entityId")).forEachBatch(String.class, 1000, new ForEachBatchBlock<>() - { - @Override - public boolean accept(String entityId) - { - return null != entityId; - } - - @Override - public void exec(List entityIds) - { - // delete the related list data for this block - deleteRelatedListData(user, container, entityIds); - } - }); - } - - // delete the related list data for this block of entityIds - private void deleteRelatedListData(User user, Container container, List entityIds) - { - // Build up set of entityIds and AttachmentParents - List attachmentParents = new ArrayList<>(); - - // Delete Attachments - if (hasAttachmentProperties()) - { - for (String entityId : entityIds) - { - attachmentParents.add(new ListItemAttachmentParent(entityId, container)); - } - AttachmentService.get().deleteAttachments(attachmentParents); - } - } - - @Override - protected int truncateRows(User user, Container container) throws QueryUpdateServiceException, SQLException - { - int result; - try (DbScope.Transaction transaction = getDbTable().getSchema().getScope().ensureTransaction()) - { - deleteRelatedListData(user, container); - result = super.truncateRows(getListUser(user, container), container); - transaction.addCommitTask(() -> ListManager.get().addAuditEvent(_list, user, "Deleted " + result + " rows from list."), DbScope.CommitTaskOption.POSTCOMMIT); - transaction.commit(); - } - - return result; - } - - @Nullable - public SimpleFilter getKeyFilter(Map map) throws InvalidKeyException - { - String keyName = _list.getKeyName(); - Object key = getField(map, keyName); - - if (null == key) - { - // Auto-increment lists might not provide a key so allow them to pass through - if (ListDefinition.KeyType.AutoIncrementInteger.equals(_list.getKeyType())) - return null; - throw new InvalidKeyException("No " + keyName + " provided for list \"" + _list.getName() + "\""); - } - - return new SimpleFilter(FieldKey.fromParts(keyName), getKeyFilterValue(key)); - } - - @NotNull - private Object getKeyFilterValue(@NotNull Object key) - { - ListDefinition.KeyType type = _list.getKeyType(); - - // Check the type of the list to ensure proper casting of the key type - if (ListDefinition.KeyType.Integer.equals(type) || ListDefinition.KeyType.AutoIncrementInteger.equals(type)) - return isIntegral(key) ? key : Integer.valueOf(key.toString()); - - return key.toString(); - } - - @Nullable - private Object getField(Map map, String key) - { - /* TODO: this is very strange, we have a TableInfo we should be using its ColumnInfo objects to figure out aliases, we don't need to guess */ - Object value = map.get(key); - - if (null == value) - value = map.get(key + "_"); - - if (null == value) - value = map.get(getDbTable().getSqlDialect().legalNameFromName(key)); - - return value; - } - - /** - * Delegate class to generate an AttachmentParent - */ - public static class ListItemAttachmentParentFactory implements AttachmentParentFactory - { - @Override - public AttachmentParent generateAttachmentParent(String entityId, Container c) - { - return new ListItemAttachmentParent(entityId, c); - } - } - - /** - * Get Domain from list definition, unless null then get from super - */ - @Override - protected Domain getDomain() - { - return _list != null? - _list.getDomain() : - super.getDomain(); - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentParentFactory; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.LookupResolutionType; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.Selector.ForEachBatchBlock; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.DataIteratorBuilder; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; +import org.labkey.api.dataiterator.ImportProgress; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.IPropertyValidator; +import org.labkey.api.exp.property.ValidatorContext; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.lists.permissions.ManagePicklistsPermission; +import org.labkey.api.query.AbstractQueryUpdateService; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DefaultQueryUpdateService; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.PropertyValidationError; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.ValidationError; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.security.ElevatedUser; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.MoveEntitiesPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.usageMetrics.SimpleMetricsService; +import org.labkey.api.util.GUID; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.writer.VirtualFile; +import org.labkey.list.view.ListItemAttachmentParent; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.labkey.api.util.IntegerUtils.isIntegral; + +/** + * Implementation of QueryUpdateService for Lists + */ +public class ListQueryUpdateService extends DefaultQueryUpdateService +{ + private final ListDefinitionImpl _list; + private static final String ID = "entityId"; + + public ListQueryUpdateService(ListTable queryTable, TableInfo dbTable, @NotNull ListDefinition list) + { + super(queryTable, dbTable, createMVMapping(queryTable.getList().getDomain())); + _list = (ListDefinitionImpl) list; + } + + @Override + public void configureDataIteratorContext(DataIteratorContext context) + { + if (context.getInsertOption().batch) + { + context.setMaxRowErrors(100); + context.setFailFast(false); + } + + context.putConfigParameter(ConfigParameters.TrimStringRight, Boolean.TRUE); + } + + @Override + protected @Nullable AttachmentParentFactory getAttachmentParentFactory() + { + return new ListItemAttachmentParentFactory(); + } + + @Override + protected Map getRow(User user, Container container, Map listRow) throws InvalidKeyException + { + Map ret = null; + + if (null != listRow) + { + SimpleFilter keyFilter = getKeyFilter(listRow); + + if (null != keyFilter) + { + TableInfo queryTable = getQueryTable(); + Map raw = new TableSelector(queryTable, keyFilter, null).getMap(); + + if (null != raw && !raw.isEmpty()) + { + ret = new CaseInsensitiveHashMap<>(); + + // EntityId + ret.put("EntityId", raw.get("entityid")); + + for (DomainProperty prop : _list.getDomainOrThrow().getProperties()) + { + String propName = prop.getName(); + ColumnInfo column = queryTable.getColumn(propName); + Object value = column.getValue(raw); + if (value != null) + ret.put(propName, value); + } + } + } + } + + return ret; + } + + + @Override + public List> insertRows(User user, Container container, List> rows, BatchValidationException errors, + @Nullable Map configParameters, Map extraScriptContext) + { + for (Map row : rows) + { + aliasColumns(getColumnMapping(), row); + } + + DataIteratorContext context = getDataIteratorContext(errors, InsertOption.INSERT, configParameters); + recordDataIteratorUsed(configParameters); + List> result = this._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + + if (null != result) + { + ListManager mgr = ListManager.get(); + + for (Map row : result) + { + if (null != row.get(ID)) + { + // Audit each row + String entityId = (String) row.get(ID); + String newRecord = mgr.formatAuditItem(_list, user, row); + + mgr.addAuditEvent(_list, user, container, "A new list record was inserted", entityId, null, newRecord); + } + } + + if (!result.isEmpty() && !errors.hasErrors()) + mgr.indexList(_list); + } + + return result; + } + + private User getListUser(User user, Container container) + { + if (_list.isPicklist() && container.hasPermission(user, ManagePicklistsPermission.class)) + { + // if the list is a picklist and you have permission to manage picklists, that equates + // to having editor permission. + return ElevatedUser.ensureContextualRoles(container, user, Pair.of(DeletePermission.class, EditorRole.class)); + } + return user; + } + + @Override + protected @Nullable List> _insertRowsUsingDIB(User user, Container container, List> rows, DataIteratorContext context, @Nullable Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + return super._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + } + + @Override + protected @Nullable List> _updateRowsUsingDIB(User user, Container container, List> rows, + DataIteratorContext context, @Nullable Map extraScriptContext) + { + if (!hasPermission(user, UpdatePermission.class)) + throw new UnauthorizedException("You do not have permission to update data in this table."); + + return super._updateRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + } + + public int insertUsingDataIterator(DataLoader loader, User user, Container container, BatchValidationException errors, @Nullable VirtualFile attachmentDir, + @Nullable ImportProgress progress, boolean supportAutoIncrementKey, InsertOption insertOption, LookupResolutionType lookupResolutionType) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + User updatedUser = getListUser(user, container); + DataIteratorContext context = new DataIteratorContext(errors); + context.setFailFast(false); + context.setInsertOption(insertOption); // this method is used by ListImporter and BackgroundListImporter + context.setSupportAutoIncrementKey(supportAutoIncrementKey); + context.setLookupResolutionType(lookupResolutionType); + setAttachmentDirectory(attachmentDir); + TableInfo ti = _list.getTable(updatedUser); + + if (null != ti) + { + try (DbScope.Transaction transaction = ti.getSchema().getScope().ensureTransaction()) + { + int imported = _importRowsUsingDIB(updatedUser, container, loader, null, context, new HashMap<>()); + + if (!errors.hasErrors()) + { + //Make entry to audit log if anything was inserted + if (imported > 0) + ListManager.get().addAuditEvent(_list, updatedUser, "Bulk " + (insertOption.updateOnly ? "updated " : (insertOption.mergeRows ? "imported " : "inserted ")) + imported + " rows to list."); + + transaction.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); + transaction.commit(); + + return imported; + } + + return 0; + } + } + + return 0; + } + + + @Override + public int mergeRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, + @Nullable Map configParameters, Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to update data in this table."); + + recordDataIteratorUsed(configParameters); + return _importRowsUsingDIB(getListUser(user, container), container, rows, null, getDataIteratorContext(errors, InsertOption.MERGE, configParameters), extraScriptContext); + } + + + @Override + public int importRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, + Map configParameters, Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + recordDataIteratorUsed(configParameters); + DataIteratorContext context = getDataIteratorContext(errors, InsertOption.IMPORT, configParameters); + int count = _importRowsUsingDIB(getListUser(user, container), container, rows, null, context, extraScriptContext); + if (count > 0 && !errors.hasErrors()) + ListManager.get().indexList(_list); + return count; + } + + @Override + public List> updateRows(User user, Container container, List> rows, List> oldKeys, + BatchValidationException errors, @Nullable Map configParameters, Map extraScriptContext) + throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to update data into this table."); + + List> result = super.updateRows(getListUser(user, container), container, rows, oldKeys, errors, configParameters, extraScriptContext); + if (!result.isEmpty()) + ListManager.get().indexList(_list); + return result; + } + + @Override + protected Map updateRow(User user, Container container, Map row, @NotNull Map oldRow, @Nullable Map configParameters) + throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + // TODO: Check for equivalency so that attachments can be deleted etc. + + Map dps = new HashMap<>(); + for (DomainProperty dp : _list.getDomainOrThrow().getProperties()) + { + dps.put(dp.getPropertyURI(), dp); + } + + ValidatorContext validatorCache = new ValidatorContext(container, user); + + ListItm itm = new ListItm(); + itm.setEntityId((String) oldRow.get(ID)); + itm.setListId(_list.getListId()); + itm.setKey(oldRow.get(_list.getKeyName())); + + ListItem item = new ListItemImpl(_list, itm); + + if (item.getProperties() != null) + { + List errors = new ArrayList<>(); + for (Map.Entry entry : dps.entrySet()) + { + Object value = row.get(entry.getValue().getName()); + validateProperty(entry.getValue(), value, row, errors, validatorCache); + } + + if (!errors.isEmpty()) + throw new ValidationException(errors); + } + + // MVIndicators + Map rowCopy = new CaseInsensitiveHashMap<>(); + ArrayList modifiedAttachmentColumns = new ArrayList<>(); + ArrayList attachmentFiles = new ArrayList<>(); + + TableInfo qt = getQueryTable(); + for (Map.Entry r : row.entrySet()) + { + ColumnInfo column = qt.getColumn(FieldKey.fromParts(r.getKey())); + rowCopy.put(r.getKey(), r.getValue()); + + // 22747: Attachment columns + if (null != column) + { + DomainProperty dp = _list.getDomainOrThrow().getPropertyByURI(column.getPropertyURI()); + if (null != dp && isAttachmentProperty(dp)) + { + modifiedAttachmentColumns.add(column); + + // setup any new attachments + if (r.getValue() instanceof AttachmentFile file) + { + if (null != file.getFilename()) + attachmentFiles.add(file); + } + else if (r.getValue() != null && !StringUtils.isEmpty(String.valueOf(r.getValue()))) + { + throw new ValidationException("Cannot upload '" + r.getValue() + "' to Attachment type field '" + r.getKey() + "'."); + } + } + } + } + + // Attempt to include key from oldRow if not found in row (As stated in the QUS Interface) + Object newRowKey = getField(rowCopy, _list.getKeyName()); + Object oldRowKey = getField(oldRow, _list.getKeyName()); + + if (null == newRowKey && null != oldRowKey) + rowCopy.put(_list.getKeyName(), oldRowKey); + + Map result = super.updateRow(getListUser(user, container), container, rowCopy, oldRow, true, false); + + if (null != result) + { + result = getRow(user, container, result); + + if (null != result && null != result.get(ID)) + { + ListManager mgr = ListManager.get(); + String entityId = (String) result.get(ID); + + try + { + // Remove prior attachment -- only includes columns which are modified in this update + for (ColumnInfo col : modifiedAttachmentColumns) + { + Object value = oldRow.get(col.getName()); + if (null != value) + { + AttachmentService.get().deleteAttachment(new ListItemAttachmentParent(entityId, _list.getContainer()), value.toString(), user); + } + } + + // Update attachments + if (!attachmentFiles.isEmpty()) + AttachmentService.get().addAttachments(new ListItemAttachmentParent(entityId, _list.getContainer()), attachmentFiles, user); + } + catch (AttachmentService.DuplicateFilenameException | AttachmentService.FileTooLargeException e) + { + // issues 21503, 28633: turn these into a validation exception to get a nicer error + throw new ValidationException(e.getMessage()); + } + catch (IOException e) + { + throw UnexpectedException.wrap(e); + } + finally + { + for (AttachmentFile attachmentFile : attachmentFiles) + { + try { attachmentFile.closeInputStream(); } catch (IOException ignored) {} + } + } + + String oldRecord = mgr.formatAuditItem(_list, user, oldRow); + String newRecord = mgr.formatAuditItem(_list, user, result); + + // Audit + mgr.addAuditEvent(_list, user, container, "An existing list record was modified", entityId, oldRecord, newRecord); + } + } + + return result; + } + + // TODO: Consolidate with ColumnValidator and OntologyManager.validateProperty() + private boolean validateProperty(DomainProperty prop, Object value, Map newRow, List errors, ValidatorContext validatorCache) + { + //check for isRequired + if (prop.isRequired()) + { + // for mv indicator columns either an indicator or a field value is sufficient + boolean hasMvIndicator = prop.isMvEnabled() && (value instanceof ObjectProperty && ((ObjectProperty)value).getMvIndicator() != null); + if (!hasMvIndicator && (null == value || (value instanceof ObjectProperty && ((ObjectProperty)value).value() == null))) + { + if (newRow.containsKey(prop.getName()) && newRow.get(prop.getName()) == null) + { + errors.add(new PropertyValidationError("The field '" + prop.getName() + "' is required.", prop.getName())); + return false; + } + } + } + + if (null != value) + { + for (IPropertyValidator validator : prop.getValidators()) + { + if (!validator.validate(prop.getPropertyDescriptor(), value, errors, validatorCache)) + return false; + } + } + + return true; + } + + private record ListRecord(Object key, String entityId) { } + + @Override + public Map moveRows( + User _user, + Container container, + Container targetContainer, + List> rows, + BatchValidationException errors, + @Nullable Map configParameters, + @Nullable Map extraScriptContext + ) throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException + { + // Ensure the list is in scope for the target container + if (null == ListService.get().getList(targetContainer, _list.getName(), true)) + { + errors.addRowError(new ValidationException(String.format("List '%s' is not accessible from folder %s.", _list.getName(), targetContainer.getPath()))); + throw errors; + } + + User user = getListUser(_user, container); + Map> containerRows = getListRowsForMoveRows(container, user, targetContainer, rows, errors); + if (errors.hasErrors()) + throw errors; + + int fileAttachmentsMovedCount = 0; + int listAuditEventsCreatedCount = 0; + int listAuditEventsMovedCount = 0; + int listRecordsCount = 0; + int queryAuditEventsMovedCount = 0; + + if (containerRows.isEmpty()) + return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); + + AuditBehaviorType auditBehavior = configParameters != null ? (AuditBehaviorType) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior) : null; + String auditUserComment = configParameters != null ? (String) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment) : null; + boolean hasAttachmentProperties = _list.getDomainOrThrow() + .getProperties() + .stream() + .anyMatch(prop -> PropertyType.ATTACHMENT.equals(prop.getPropertyType())); + + ListAuditProvider listAuditProvider = new ListAuditProvider(); + final int BATCH_SIZE = 5_000; + boolean isAuditEnabled = auditBehavior != null && AuditBehaviorType.NONE != auditBehavior; + + try (DbScope.Transaction tx = getDbTable().getSchema().getScope().ensureTransaction()) + { + if (isAuditEnabled && tx.getAuditEvent() == null) + { + TransactionAuditProvider.TransactionAuditEvent auditEvent = createTransactionAuditEvent(targetContainer, QueryService.AuditAction.UPDATE); + auditEvent.updateCommentRowCount(containerRows.values().stream().mapToInt(List::size).sum()); + AbstractQueryUpdateService.addTransactionAuditEvent(tx, user, auditEvent); + } + + List listAuditEvents = new ArrayList<>(); + + for (GUID containerId : containerRows.keySet()) + { + Container sourceContainer = ContainerManager.getForId(containerId); + if (sourceContainer == null) + throw new InvalidKeyException("Container '" + containerId + "' does not exist."); + + if (!sourceContainer.hasPermission(user, MoveEntitiesPermission.class)) + throw new UnauthorizedException("You do not have permission to move list records out of '" + sourceContainer.getName() + "'."); + + TableInfo listTable = _list.getTable(user, sourceContainer); + if (listTable == null) + throw new QueryUpdateServiceException(String.format("Failed to retrieve table for list '%s' in folder %s.", _list.getName(), sourceContainer.getPath())); + + List records = containerRows.get(containerId); + int numRecords = records.size(); + + for (int start = 0; start < numRecords; start += BATCH_SIZE) + { + int end = Math.min(start + BATCH_SIZE, numRecords); + List batch = records.subList(start, end); + List rowPks = batch.stream().map(ListRecord::key).toList(); + + // Before trigger per batch + Map extraContext = Map.of("targetContainer", targetContainer, "keys", rowPks); + listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, true, errors, extraContext); + if (errors.hasErrors()) + throw errors; + + listRecordsCount += Table.updateContainer(getDbTable(), _list.getKeyName(), rowPks, targetContainer, user, true); + if (errors.hasErrors()) + throw errors; + + if (hasAttachmentProperties) + { + fileAttachmentsMovedCount += moveAttachments(user, sourceContainer, targetContainer, batch, errors); + if (errors.hasErrors()) + throw errors; + } + + queryAuditEventsMovedCount += QueryService.get().moveAuditEvents(targetContainer, rowPks, ListQuerySchema.NAME, _list.getName()); + listAuditEventsMovedCount += listAuditProvider.moveEvents(targetContainer, batch.stream().map(ListRecord::entityId).toList()); + + // Detailed audit events per row + if (AuditBehaviorType.DETAILED == listTable.getEffectiveAuditBehavior(auditBehavior)) + listAuditEventsCreatedCount += addDetailedMoveAuditEvents(user, sourceContainer, targetContainer, batch); + + // After trigger per batch + listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, false, errors, extraContext); + if (errors.hasErrors()) + throw errors; + } + + // Create a summary audit event for the source container + if (isAuditEnabled) + { + String comment = String.format("Moved %s to %s", StringUtilsLabKey.pluralize(numRecords, "row"), targetContainer.getPath()); + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(sourceContainer, comment, _list); + event.setUserComment(auditUserComment); + listAuditEvents.add(event); + } + + // Create a summary audit event for the target container + if (isAuditEnabled) + { + String comment = String.format("Moved %s from %s", StringUtilsLabKey.pluralize(numRecords, "row"), sourceContainer.getPath()); + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, comment, _list); + event.setUserComment(auditUserComment); + listAuditEvents.add(event); + } + } + + if (!listAuditEvents.isEmpty()) + { + AuditLogService.get().addEvents(user, listAuditEvents, true); + listAuditEventsCreatedCount += listAuditEvents.size(); + } + + tx.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); + + tx.commit(); + + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "moveEntities", "list"); + } + + return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); + } + + private Map moveRowsCounts(int fileAttachmentsMovedCount, int listAuditEventsCreated, int listAuditEventsMoved, int listRecords, int queryAuditEventsMoved) + { + return Map.of( + "fileAttachmentsMoved", fileAttachmentsMovedCount, + "listAuditEventsCreated", listAuditEventsCreated, + "listAuditEventsMoved", listAuditEventsMoved, + "listRecords", listRecords, + "queryAuditEventsMoved", queryAuditEventsMoved + ); + } + + private Map> getListRowsForMoveRows( + Container container, + User user, + Container targetContainer, + List> rows, + BatchValidationException errors + ) throws QueryUpdateServiceException + { + if (rows.isEmpty()) + return Collections.emptyMap(); + + String keyName = _list.getKeyName(); + List keys = new ArrayList<>(); + for (var row : rows) + { + Object key = getField(row, keyName); + if (key == null) + { + errors.addRowError(new ValidationException("Key field value required for moving list rows.")); + return Collections.emptyMap(); + } + + keys.add(getKeyFilterValue(key)); + } + + SimpleFilter filter = new SimpleFilter(); + FieldKey fieldKey = FieldKey.fromParts(keyName); + filter.addInClause(fieldKey, keys); + filter.addCondition(FieldKey.fromParts("Container"), targetContainer.getId(), CompareType.NEQ); + + // Request all rows without a container filter so that rows are more easily resolved across the list scope. + // Read permissions are subsequently checked upon loading a row. + TableInfo table = _list.getTable(user, container, ContainerFilter.getUnsafeEverythingFilter()); + if (table == null) + throw new QueryUpdateServiceException(String.format("Failed to resolve table for list %s in %s", _list.getName(), container.getPath())); + + Map> containerRows = new HashMap<>(); + try (var result = new TableSelector(table, PageFlowUtil.set(keyName, "Container", "EntityId"), filter, null).getResults()) + { + while (result.next()) + { + GUID containerId = new GUID(result.getString("Container")); + if (!containerRows.containsKey(containerId)) + { + var c = ContainerManager.getForId(containerId); + if (c == null) + throw new QueryUpdateServiceException(String.format("Failed to resolve container for row in list %s in %s.", _list.getName(), container.getPath())); + else if (!c.hasPermission(user, ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read list rows in all source containers."); + } + + containerRows.computeIfAbsent(containerId, k -> new ArrayList<>()); + containerRows.get(containerId).add(new ListRecord(result.getObject(fieldKey), result.getString("EntityId"))); + } + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + + return containerRows; + } + + private int moveAttachments(User user, Container sourceContainer, Container targetContainer, List records, BatchValidationException errors) + { + List parents = new ArrayList<>(); + for (ListRecord record : records) + parents.add(new ListItemAttachmentParent(record.entityId, sourceContainer)); + + int count = 0; + try + { + count = AttachmentService.get().moveAttachments(targetContainer, parents, user); + } + catch (IOException e) + { + errors.addRowError(new ValidationException("Failed to move attachments when moving list rows. Error: " + e.getMessage())); + } + + return count; + } + + private int addDetailedMoveAuditEvents(User user, Container sourceContainer, Container targetContainer, List records) + { + List auditEvents = new ArrayList<>(records.size()); + String keyName = _list.getKeyName(); + String sourcePath = sourceContainer.getPath(); + String targetPath = targetContainer.getPath(); + + for (ListRecord record : records) + { + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, "An existing list record was moved", _list); + event.setListItemEntityId(record.entityId); + event.setOldRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", sourcePath, keyName, record.key.toString()))); + event.setNewRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", targetPath, keyName, record.key.toString()))); + auditEvents.add(event); + } + + AuditLogService.get().addEvents(user, auditEvents, true); + + return auditEvents.size(); + } + + @Override + protected Map deleteRow(User user, Container container, Map oldRowMap) throws InvalidKeyException, QueryUpdateServiceException, SQLException + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to delete data from this table."); + + Map result = super.deleteRow(getListUser(user, container), container, oldRowMap); + + if (null != result) + { + String entityId = (String) result.get(ID); + + if (null != entityId) + { + ListManager mgr = ListManager.get(); + String deletedRecord = mgr.formatAuditItem(_list, user, result); + + // Audit + mgr.addAuditEvent(_list, user, container, "An existing list record was deleted", entityId, deletedRecord, null); + + // Remove attachments + if (hasAttachmentProperties()) + AttachmentService.get().deleteAttachments(new ListItemAttachmentParent(entityId, container)); + + // Clean up Search indexer + if (!result.isEmpty()) + mgr.deleteItemIndex(_list, entityId); + } + } + + return result; + } + + + // Deletes attachments & discussions, and removes list documents from full-text search index. + public void deleteRelatedListData(final User user, final Container container) + { + // Unindex all item docs and the entire list doc + ListManager.get().deleteIndexedList(_list); + + // Delete attachments and discussions associated with a list in batches of 1,000 + new TableSelector(getDbTable(), Collections.singleton("entityId"), SimpleFilter.createContainerFilter(container), null).forEachBatch(String.class, 1000, new ForEachBatchBlock<>() + { + @Override + public boolean accept(String entityId) + { + return null != entityId; + } + + @Override + public void exec(List entityIds) + { + // delete the related list data for this block + deleteRelatedListData(user, container, entityIds); + } + }); + } + + // delete the related list data for this block of entityIds + private void deleteRelatedListData(User user, Container container, List entityIds) + { + // Build up set of entityIds and AttachmentParents + List attachmentParents = new ArrayList<>(); + + // Delete Attachments + if (hasAttachmentProperties()) + { + for (String entityId : entityIds) + { + attachmentParents.add(new ListItemAttachmentParent(entityId, container)); + } + AttachmentService.get().deleteAttachments(attachmentParents); + } + } + + @Override + protected int truncateRows(User user, Container container) throws QueryUpdateServiceException, SQLException + { + int result; + try (DbScope.Transaction transaction = getDbTable().getSchema().getScope().ensureTransaction()) + { + deleteRelatedListData(user, container); + result = super.truncateRows(getListUser(user, container), container); + transaction.addCommitTask(() -> ListManager.get().addAuditEvent(_list, user, "Deleted " + result + " rows from list."), DbScope.CommitTaskOption.POSTCOMMIT); + transaction.commit(); + } + + return result; + } + + @Nullable + public SimpleFilter getKeyFilter(Map map) throws InvalidKeyException + { + String keyName = _list.getKeyName(); + Object key = getField(map, keyName); + + if (null == key) + { + // Auto-increment lists might not provide a key so allow them to pass through + if (ListDefinition.KeyType.AutoIncrementInteger.equals(_list.getKeyType())) + return null; + throw new InvalidKeyException("No " + keyName + " provided for list \"" + _list.getName() + "\""); + } + + return new SimpleFilter(FieldKey.fromParts(keyName), getKeyFilterValue(key)); + } + + @NotNull + private Object getKeyFilterValue(@NotNull Object key) + { + ListDefinition.KeyType type = _list.getKeyType(); + + // Check the type of the list to ensure proper casting of the key type + if (ListDefinition.KeyType.Integer.equals(type) || ListDefinition.KeyType.AutoIncrementInteger.equals(type)) + return isIntegral(key) ? key : Integer.valueOf(key.toString()); + + return key.toString(); + } + + @Nullable + private Object getField(Map map, String key) + { + /* TODO: this is very strange, we have a TableInfo we should be using its ColumnInfo objects to figure out aliases, we don't need to guess */ + Object value = map.get(key); + + if (null == value) + value = map.get(key + "_"); + + if (null == value) + value = map.get(getDbTable().getSqlDialect().legalNameFromName(key)); + + return value; + } + + /** + * Delegate class to generate an AttachmentParent + */ + public static class ListItemAttachmentParentFactory implements AttachmentParentFactory + { + @Override + public AttachmentParent generateAttachmentParent(String entityId, Container c) + { + return new ListItemAttachmentParent(entityId, c); + } + } + + /** + * Get Domain from list definition, unless null then get from super + */ + @Override + protected Domain getDomain() + { + return _list != null? + _list.getDomain() : + super.getDomain(); + } +} diff --git a/list/src/org/labkey/list/view/ListQueryView.java b/list/src/org/labkey/list/view/ListQueryView.java index a8a79d81e3e..574c07bf275 100644 --- a/list/src/org/labkey/list/view/ListQueryView.java +++ b/list/src/org/labkey/list/view/ListQueryView.java @@ -1,89 +1,87 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.view; - -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryView; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.list.model.ListQuerySchema; -import org.springframework.validation.BindException; - -public class ListQueryView extends QueryView -{ - private final ListDefinition _list; - - public ListQueryView(ListDefinition def, ListQuerySchema schema, QuerySettings settings, BindException errors) - { - super(schema, settings, errors); - _list = def; - init(); - } - - public ListQueryView(ListQueryForm form, BindException errors) - { - super(form, errors); - _list = form.getList(); - init(); - } - - protected void init() - { - setShowExportButtons(_list.getAllowExport()); - setShowUpdateColumn(true); - setAllowableContainerFilterTypes( - ContainerFilter.Type.Current, - ContainerFilter.Type.CurrentAndSubfoldersPlusShared, - ContainerFilter.Type.CurrentPlusProjectAndShared, - ContainerFilter.Type.AllFolders - ); - } - - @Override - protected boolean canDelete() - { - return super.canDelete() && _list.getAllowDelete(); - } - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - super.populateButtonBar(view, bar); - - if (getViewContext().hasPermission(DesignListPermission.class)) - { - ActionURL designURL = getList().urlShowDefinition(); - URLHelper returnUrl = getSettings() != null ? getSettings().getReturnUrlHelper() : null; - designURL.addReturnUrl(returnUrl != null ? returnUrl : getViewContext().getActionURL()); - ActionButton btnUpload = new ActionButton("Design", designURL); - bar.add(btnUpload); - } - if (canDelete()) - bar.add(super.createDeleteAllRowsButton("list")); - } - - public ListDefinition getList() - { - return _list; - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.view; + +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryView; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.list.model.ListQuerySchema; +import org.springframework.validation.BindException; + +public class ListQueryView extends QueryView +{ + private final ListDefinition _list; + + public ListQueryView(ListDefinition def, ListQuerySchema schema, QuerySettings settings, BindException errors) + { + super(schema, settings, errors); + _list = def; + init(); + } + + public ListQueryView(ListQueryForm form, BindException errors) + { + super(form, errors); + _list = form.getList(); + init(); + } + + protected void init() + { + setShowExportButtons(_list.getAllowExport()); + setShowUpdateColumn(true); + setAllowableContainerFilterTypes( + ContainerFilter.Type.Current, + ContainerFilter.Type.CurrentAndSubfoldersPlusShared, + ContainerFilter.Type.CurrentPlusProjectAndShared, + ContainerFilter.Type.AllFolders + ); + } + + @Override + protected boolean canDelete() + { + return super.canDelete() && _list.getAllowDelete(); + } + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + super.populateButtonBar(view, bar); + + if (getViewContext().hasPermission(DesignListPermission.class)) + { + ActionURL designURL = getList().urlShowDefinition(); + URLHelper returnUrl = getSettings() != null ? getSettings().getReturnUrlHelper() : null; + designURL.addReturnUrl(returnUrl != null ? returnUrl : getViewContext().getActionURL()); + ActionButton btnUpload = new ActionButton("Design", designURL); + bar.add(btnUpload); + } + } + + public ListDefinition getList() + { + return _list; + } +} diff --git a/list/src/org/labkey/list/view/truncateListData.jsp b/list/src/org/labkey/list/view/truncateListData.jsp new file mode 100644 index 00000000000..95f1532ea18 --- /dev/null +++ b/list/src/org/labkey/list/view/truncateListData.jsp @@ -0,0 +1,70 @@ +<% +/* + * Copyright (c) 2009-2016 LabKey Corporation + * + * 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 + * + * http://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. + */ +%> +<%@ page import="org.labkey.api.data.Container" %> +<%@ page import="org.labkey.api.data.TableInfo" %> +<%@ page import="org.labkey.api.data.TableSelector" %> +<%@ page import="org.labkey.api.exp.list.ListDefinition" %> +<%@ page import="org.labkey.api.exp.list.ListService" %> +<%@ page import="org.labkey.list.controllers.ListController" %> +<%@ page import="java.util.ArrayList" %> +<%@ page import="java.util.HashMap" %> +<%@ page import="java.util.List" %> +<%@ page import="java.util.Map" %> +<%@ page import="java.util.Objects" %> +<%@ page import="org.labkey.list.model.ListQuerySchema" %> +<%@ page import="org.labkey.api.security.User" %> +<%@ page extends="org.labkey.api.jsp.FormPage" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib"%> +<% + ListController.ListDeletionForm form = (ListController.ListDeletionForm)getModelBean(); + Map> definitions = new HashMap<>(); + Container currentContainer = form.getContainer(); + String currentPath = currentContainer.getPath(); + User user = form.getUser(); + ListQuerySchema listQuerySchema = new ListQuerySchema(user, currentContainer); + + form.getListContainerMap() + .stream() + .map(pair -> ListService.get().getList(pair.second, pair.first)) + .filter(Objects::nonNull) + .forEach(listDef -> { + var container = listDef.getContainer(); + if (!definitions.containsKey(container)) + definitions.put(container, new ArrayList<>()); + definitions.get(container).add(listDef); + }); +%> + +

Are you sure you want to delete all data in <%= h(currentPath) %> from the following Lists? This action cannot be undone and will result in an empty list.

+ +<% for (var entry : definitions.entrySet()) { %> +
+ List definitions defined in <%= h(entry.getKey().getPath()) %>: +
    + <% for (var listDef : entry.getValue()) { + TableInfo table = listQuerySchema.getTable(listDef.getName()); + long count = (table != null) ? new TableSelector(table).getRowCount() : 0; + %> +
  • + <%= simpleLink(listDef.getName(), listDef.urlFor(ListController.GridAction.class, listDef.getContainer())) %> + — <%= count %> row<%= h(count != 1 ? "s" : "") %> in <%= h(currentPath) %> +
  • + <% } %> +
+
+<% } %> From a7feb9e6f3f64b2eeabaf1b213831aa729ceaa6d Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 3 Mar 2026 12:31:24 -0800 Subject: [PATCH 2/4] CRLF --- api/src/org/labkey/api/query/QueryView.java | 6854 ++++++++--------- .../list/controllers/ListController.java | 2512 +++--- .../labkey/list/model/ListDefinitionImpl.java | 1704 ++-- .../labkey/list/model/ListManagerSchema.java | 526 +- .../list/model/ListQueryUpdateService.java | 1770 ++--- .../org/labkey/list/view/ListQueryView.java | 174 +- 6 files changed, 6770 insertions(+), 6770 deletions(-) diff --git a/api/src/org/labkey/api/query/QueryView.java b/api/src/org/labkey/api/query/QueryView.java index 8cd3b6941ff..bdf1e5c638e 100644 --- a/api/src/org/labkey/api/query/QueryView.java +++ b/api/src/org/labkey/api/query/QueryView.java @@ -1,3427 +1,3427 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.api.query; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.poi.ss.usermodel.Workbook; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.action.ApiQueryResponse; -import org.labkey.api.admin.notification.NotificationService; -import org.labkey.api.attachments.ByteArrayAttachmentFile; -import org.labkey.api.compliance.ComplianceService; -import org.labkey.api.data.AbstractTableInfo; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.AnalyticsProviderItem; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ButtonBarConfig; -import org.labkey.api.data.ColumnHeaderType; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerFilterable; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.DetailsColumn; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.ExcelWriter; -import org.labkey.api.data.HtmlExportWriter; -import org.labkey.api.data.MenuButton; -import org.labkey.api.data.PanelButton; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.Results; -import org.labkey.api.data.ResultsImpl; -import org.labkey.api.data.ShowRows; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Sort; -import org.labkey.api.data.TSVGridWriter; -import org.labkey.api.data.TSVWriter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.UpdateColumn; -import org.labkey.api.data.dialect.SqlDialect; -import org.labkey.api.exp.RawValueColumn; -import org.labkey.api.query.snapshot.QuerySnapshotService; -import org.labkey.api.reports.Report; -import org.labkey.api.reports.ReportService; -import org.labkey.api.reports.report.ReportUrls; -import org.labkey.api.reports.report.r.RReport; -import org.labkey.api.reports.report.view.ReportUtil; -import org.labkey.api.reports.report.view.RunReportView; -import org.labkey.api.reports.report.view.ScriptReportBean; -import org.labkey.api.rstudio.RStudioService; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.settings.ResourceURL; -import org.labkey.api.study.UnionTable; -import org.labkey.api.study.reports.CrosstabReport; -import org.labkey.api.util.ExceptionUtil; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.ResponseHelper; -import org.labkey.api.util.StringExpression; -import org.labkey.api.util.StringExpressionFactory; -import org.labkey.api.util.StringUtilsLabKey; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.GridView; -import org.labkey.api.view.HttpView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTrailConfig; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.PopupMenuView; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.ViewServlet; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.ClientDependency; -import org.labkey.api.visualization.GenericChartReport; -import org.labkey.api.visualization.TimeChartReport; -import org.labkey.api.writer.ContainerUser; -import org.labkey.api.writer.HtmlWriter; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; - -import java.io.BufferedOutputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.net.URISyntaxException; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeMap; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.stream.Collectors; - -import static org.labkey.api.action.SpringActionController.ERROR_MSG; -import static org.labkey.api.util.DOM.P; -import static org.labkey.api.util.DOM.cl; - -/** - * View that generates the majority of standard data grids/tables in the LabKey Server UI. - * The backing query is lazily invoked when it comes time to render the QueryView. - */ -public class QueryView extends WebPartView implements ContainerUser -{ - public static final String EXPERIMENTAL_GENERIC_DETAILS_URL = "generic-details-url"; - - public static final String EXCEL_WEB_QUERY_EXPORT_TYPE = "excelWebQuery"; - public static final String DATAREGIONNAME_DEFAULT = "query"; - - private static final Logger _log = LogManager.getLogger(QueryView.class); - private static final Map _exportScriptFactories = new ConcurrentSkipListMap<>(); - - protected static final String INSERT_DATA_TEXT = "Insert Data"; - protected static final String INSERT_ROW_TEXT = "Insert New Row"; - protected static final String IMPORT_BULK_DATA_TEXT = "Import Bulk Data"; - - protected DataRegion.ButtonBarPosition _buttonBarPosition = DataRegion.ButtonBarPosition.TOP; - private ButtonBarConfig _buttonBarConfig = null; - private boolean _showDetailsColumn = true; - private boolean _showUpdateColumn = true; - private DataRegion.MessageSupplier _messageSupplier; - - private String _linkTarget; - - // Overrides for any URLs that might already be set on the TableInfo - private DetailsURL _updateURL; - private DetailsURL _detailsURL; - private String _insertURL; - private String _importURL; - private String _deleteURL; - - private boolean _hasExportRStudioPanel = false; - - - public static void register(ExportScriptFactory factory) - { - register(factory, false); - } - - public static void register(ExportScriptFactory factory, boolean overrideBaseFactory) - { - if (!overrideBaseFactory) - assert null == _exportScriptFactories.get(factory.getScriptType()); - - _exportScriptFactories.put(factory.getScriptType(), factory); - } - - public static ExportScriptFactory getExportScriptFactory(String type) - { - return _exportScriptFactories.get(type); - } - - static public QueryView create(ViewContext context, UserSchema schema, QuerySettings settings, BindException errors) - { - return schema.createView(context, settings, errors); - } - - static public QueryView create(QueryForm form, BindException errors) - { - form.ensureSchemaExists(); - - return create(form.getViewContext(), form.getSchema(), form.getQuerySettings(), errors); - } - - private QueryDefinition _queryDef; - private CustomView _customView; - private UserSchema _schema; - private Errors _errors; - private final List _parseErrors = new ArrayList<>(); - private QuerySettings _settings; - private boolean _showRecordSelectors = false; - - private boolean _shadeAlternatingRows = true; - private boolean _showFilterDescription = true; - private boolean _showBorders = true; - private boolean _showSurroundingBorder = true; - private Report _report; - - private boolean _showExportButtons = true; - private boolean _showRStudioButton = false; // might want show by default if rstudio is configured - private boolean _showInsertNewButton = true; - private boolean _showImportDataButton = true; - private boolean _showDeleteButton = true; - private boolean _showDeleteButtonConfirmationText = true; - private boolean _showConfiguredButtons = true; - private boolean _allowExportExternalQuery = true; - - private static final Set STANDARD_CONTAINER_FILTERS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders))); - - /** The container filters (called "Folder Filter" in the UI) that should be available to users in the Views menu */ - @NotNull - private Set _allowableContainerFilterTypes = STANDARD_CONTAINER_FILTERS; - private boolean _useQueryViewActionExportURLs = false; - private boolean _printView = false; - private boolean _exportView = false; - private boolean _apiResponseView = false; - private boolean _showPagination = true; - private boolean _showPaginationCount = true; - private boolean _showReports = true; - private ReportService.ItemFilter _itemFilter = DEFAULT_ITEM_FILTER; - - public static ReportService.ItemFilter DEFAULT_ITEM_FILTER = (type, label) -> - { - if (ReportService.get().getGlobalItemFilterTypes().contains(type)) return true; - if (RReport.TYPE.equals(type)) return true; - return CrosstabReport.TYPE.equals(type); - }; - - private TableInfo _table; - - public QueryView(QueryForm form, Errors errors) - { - this(form.getSchema(), form.getQuerySettings(), errors); - } - - - /** - * Must call setSettings before using the view - */ - public QueryView(UserSchema schema) - { - super(FrameType.DIV); - setSchema(schema); - } - - @Override - public void setTitle(CharSequence title) - { - super.setTitle(title); - if (StringUtils.isNotEmpty(title) && getFrame()==FrameType.DIV) - setFrame(FrameType.PORTAL); - } - - - /** Use the constructor that takes an Errors object instead */ - @Deprecated - protected QueryView(UserSchema schema, QuerySettings settings) - { - this(schema, settings, null); - } - - public QueryView(UserSchema schema, QuerySettings settings, @Nullable Errors errors) - { - this(schema); - // TODO: stop passing in null Errors. For now, new one up if null. - _errors = errors != null ? errors : new BindException(new Object(), "form"); - if (null != settings) - setSettings(settings); - } - - public QuerySettings getSettings() - { - return _settings; - } - - - protected void setSettings(QuerySettings settings) - { - if (null != _settings || null == _schema) - throw new IllegalStateException(); - _settings = settings; - _queryDef = settings.getQueryDef(_schema); - // Disable external exports (scripts, etc) since they will run in a different HTTP session that doesn't - // have access to the temporary query - if (_queryDef != null) - { - _allowExportExternalQuery &= !_queryDef.isTemporary(); - } - _customView = settings.getCustomView(getViewContext(), getQueryDef()); - } - - - protected int getMaxRows() - { - if (getShowRows() == ShowRows.NONE) - return Table.NO_ROWS; - if (getShowRows() != ShowRows.PAGINATED) - return Table.ALL_ROWS; - return getSettings().getMaxRows(); - } - - - protected long getOffset() - { - if (getShowRows() != ShowRows.PAGINATED) - return 0; - return getSettings().getOffset(); - } - - protected ShowRows getShowRows() - { - return getSettings().getShowRows(); - } - - protected String getSelectionKey() - { - return getSettings().getSelectionKey(); - } - - /** - * Returns an ActionURL for the "returnUrl" parameter or the current ActionURL if none. - */ - public URLHelper getReturnUrl() - { - return getSettings().getReturnUrlHelper(ViewServlet.getRequestURL()); - } - - protected boolean verboseErrors() - { - return true; - } - - - protected boolean ignoreUserFilter() - { - return (getViewContext().getRequest() != null && getViewContext().getRequest().getParameter(param(QueryParam.ignoreFilter)) != null) || - (getSettings() != null && getSettings().getIgnoreUserFilter()); - } - - // ignores filters on the custom view but not those added through query settings - protected boolean ignoreViewFilter() - { - return getSettings() != null && getSettings().getIgnoreViewFilter(); - } - - protected void renderErrors(HtmlWriter out, String message, List errors) - { - boolean isEditable = getQueryDef() != null && getQueryDef().canEdit(getUser()) && getQueryDef().isSqlEditable(); - P( - cl("labkey-error"), - message, - isEditable ? HtmlString.NBSP : null, - isEditable ? LinkBuilder.simpleLink("Edit Query", Objects.requireNonNull(getSchema().urlFor(QueryAction.sourceQuery, getQueryDef()))) : null - ).appendTo(out); - - Set seen = new HashSet<>(); - - if (verboseErrors()) - { - for (Throwable e : errors) - { - if (e instanceof QueryParseException) - { - out.write(e.getMessage()); - } - else - { - out.write(e.toString()); - } - - String resolveURL = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveURL); - if (null != resolveURL && seen.add(resolveURL)) - { - String resolveText = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveText); - if (getUser().isPlatformDeveloper()) - { - out.write(" "); - out.write(LinkBuilder.labkeyLink(Objects.toString(resolveText, "resolve"), resolveURL)); - } - } - out.write(HtmlString.BR); - } - } - } - - /* delay load menu, because it is usually visible==false */ - private class QueryNavTreeMenuButton extends MenuButton - { - private boolean populated = false; - - QueryNavTreeMenuButton(String label) - { - super(label); - setVisible(false); - } - - @Override - public void setVisible(boolean visible) - { - if (visible && !populated) - { - populateMenu(); - populated = true; - } - super.setVisible(visible); - } - - private void populateMenu() - { - if (getQueryDef() != null) - { - NavTree editQueryItem; - if (getQueryDef().isSqlEditable() && getQueryDef().canEdit(getUser())) - editQueryItem = new NavTree("Edit Source", getSchema().urlFor(QueryAction.sourceQuery, getQueryDef())); - else - editQueryItem = new NavTree("View Definition", getSchema().urlFor(QueryAction.schemaBrowser, getQueryDef())); - addMenuItem(editQueryItem); - - if (getQueryDef().isMetadataEditable() && getQueryDef().canEditMetadata(getUser())) - { - NavTree editMetadataItem = new NavTree("Edit Metadata", getSchema().urlFor(QueryAction.metadataQuery, getQueryDef())); - addMenuItem(editMetadataItem); - } - } - - addSeparator(); - - if (getSchema().shouldRenderTableList()) - { - String current = getQueryDef() != null ? getQueryDef().getName() : null; - URLHelper target = urlRefreshQuery(); - - for (QueryDefinition query : getSchema().getTablesAndQueries(true)) - { - String name = query.getName(); - NavTree item = new NavTree(name, target.clone().replaceParameter(param(QueryParam.queryName), name)); - // Intentionally don't set the description so we can avoid having to instantiate all of the TableInfos, - // which can be expensive for some schemas - if (name.equals(current)) - item.setStrong(true); - item.setImageSrc(new ResourceURL("/reports/grid.gif")); - item.setImageCls("fa fa-table"); - addMenuItem(item); - } - } - else - { - ActionURL schemaBrowserURL = PageFlowUtil.urlProvider(QueryUrls.class).urlSchemaBrowser(getContainer(), getSchema().getName()); - addMenuItem("Schema Browser", schemaBrowserURL); - } - } - } - - - public MenuButton createQueryPickerButton(String label) - { - return new QueryNavTreeMenuButton(label); - } - - - @Override - public User getUser() - { - return _schema.getUser(); - } - - public UserSchema getSchema() - { - return _schema; - } - - protected void setSchema(UserSchema schema) - { - if (null != _settings || null != _schema) - throw new IllegalStateException(); - _schema = schema; - } - - @Override - public Container getContainer() - { - return _schema.getContainer(); - } - - protected StringExpression urlExpr(QueryAction action) - { - StringExpression expr = switch (action) - { - case detailsQueryRow -> _detailsURL; - case updateQueryRow -> _updateURL; - default -> null; - - // NOTE: details/update URL may not get picked up from TableInfo if subclass overrides createTable() - // but that case should use QueryView.setDetailsURL/setUpdateURL() anyway - }; - - if (null == expr) - expr = getQueryDef().urlExpr(action, _schema.getContainer()); - - if (expr == null) - return null; - - // Don't append the returnUrl parameter in API responses - if (!isApiResponseView()) - { - switch (action) - { - case detailsQueryRow: - case updateQueryRow: - case insertQueryRow: - case importData: - case updateQueryRows: - case deleteQueryRows: - { - // ICK - URLHelper returnUrl = getReturnUrl(); - if (returnUrl != null) - { - String encodedReturnURL = PageFlowUtil.encode(returnUrl.getLocalURIString()); - expr = ((StringExpressionFactory.AbstractStringExpression) expr).addParameter(ActionURL.Param.returnUrl.name(), encodedReturnURL); - } - } - } - } - - return expr; - } - - @Nullable - protected ActionURL urlFor(QueryAction action) - { - ActionURL ret = null; - switch (action) - { - case deleteQueryRows: - if (null != _deleteURL) - ret = DetailsURL.fromString(_deleteURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - case detailsQueryRow: - // TODO kinda suspect... since this is a per-row url - if (null != _detailsURL) - ret = _detailsURL.getActionURL(); - break; - case updateQueryRow: - // TODO also kinda suspect... - if (null != _updateURL) - ret = _updateURL.getActionURL(); - break; - case insertQueryRow: - if (null != _insertURL) - ret = DetailsURL.fromString(_insertURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - case importData: - if (null != _importURL) - ret = DetailsURL.fromString(_importURL).setContainerContext(_schema.getContainer()).getActionURL(); - break; - } - - if (null == ret && null != getQueryDef()) - ret = _schema.urlFor(action, getQueryDef()); - - if (ret == null) - { - return null; - } - - // Issue 11280: Export URLs don't include the query's base sort/filter. - // The solution is to expand the custom view's saved sort/filter before adding the base sort/filter. - // NOTE: This is a temporary solution. - // - // We won't need to expand the saved custom view filters or analyticsProviders. Filters can be applied - // in any order and the analyticsProviders don't make much sense in the exported xls or tsv files. - // - // The correct long term solution is to (a) create proper QueryView subclasses using UserSchema.createView() - // and (b) use POST instead of GET for the export actions (or others) to match the LABKEY.QueryWebPart config behavior. - // Using POST is necessary since the LABKEY.QueryWebPart config expresses other options (column lists, grid rendering options, etc) that can't be expressed on URLs. - // - // Issue 17313: Exporting from a grid should respect "Apply View Filter" state - if (_customView != null) - { - if (_customView.getName() != null) - ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.viewName, _customView.getName()); - - if (!ignoreUserFilter() && _customView != null && _customView.hasFilterOrSort()) - { - _customView.applyFilterAndSortToURL(ret, DATAREGIONNAME_DEFAULT); - } - } - - // Applying the base sort/filter to the url is lossy in that anyone consuming the url can't - // determine if the sort/filter originated from QuerySettings or from a user applied sort/filter. - getSettings().getBaseFilter().applyToURL(ret, DATAREGIONNAME_DEFAULT); - - if (!getSettings().getBaseSort().getSortList().isEmpty()) - getSettings().getBaseSort().applyToURL(ret, DATAREGIONNAME_DEFAULT, true); - - switch (action) - { - case deleteQuery: - case sourceQuery: - break; - case detailsQueryRow: - case updateQueryRow: - case insertQueryRow: - case importData: - case updateQueryRows: - case deleteQueryRows: - ret.addReturnUrl(getReturnUrl()); - break; - case editSnapshot: - ret.addParameter("snapshotName", getSettings().getQueryName()); - case createSnapshot: - - case exportRowsExcel: - case exportRowsXLSX: - case exportRowsTsv: - case exportScript: - case signRowsExcel: - case signRowsXLSX: - case signRowsTsv: - case selectAll: - case printRows: - { - if (_useQueryViewActionExportURLs) - { - ret = getViewContext().cloneActionURL(); - ret.addParameter("exportType", action.name()); - ret.addParameter("dataRegionName", getExportRegionName()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(getExportRegionName() + ".maxRows"); - ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); - break; - } - ActionURL expandedURL = getViewContext().cloneActionURL(); - addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); - // Copy the other parameters that aren't scoped to the data region as well. Some exports may use them. - // For example, see issue 15451 - for (Map.Entry entry : expandedURL.getParameterMap().entrySet()) - { - String name = entry.getKey(); - // schemaName isn't prefixed with the data region name, and don't specify a special data region name - if (!name.equals("schemaName") && !name.equals("dataRegionName") && !name.startsWith(getDataRegionName() + ".") && !name.startsWith(DATAREGIONNAME_DEFAULT + ".")) - { - for (String value : entry.getValue()) - { - ret.addParameter(entry.getKey(), value); - } - } - } - - ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.selectionKey, getSelectionKey()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); - ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); - break; - } - case excelWebQueryDefinition: - { - if (_useQueryViewActionExportURLs) - { - ActionURL expandedURL = getViewContext().cloneActionURL(); - expandedURL.addParameter("exportType", EXCEL_WEB_QUERY_EXPORT_TYPE); - expandedURL.addParameter("exportRegion", getDataRegionName()); - ret.addParameter("queryViewActionURL", expandedURL.getLocalURIString()); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(getExportRegionName() + ".maxRows"); - ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); - break; - } - ActionURL expandedURL = getViewContext().cloneActionURL(); - addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); - - // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel - ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); - ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); - break; - } - case createRReport: - ScriptReportBean bean = new ScriptReportBean(); - bean.setReportType(RReport.TYPE); - bean.setSchemaName(_schema.getSchemaName()); - bean.setQueryName(getSettings().getQueryName()); - bean.setViewName(getSettings().getViewName()); - bean.setDataRegionName(getDataRegionName()); - - bean.setRedirectUrl(getReturnUrl().getLocalURIString()); - return ReportUtil.getScriptReportDesignerURL(_viewContext, bean); - } - return ret; - } - - protected ActionButton actionButton(String label, QueryAction action) - { - return actionButton(label, action, null, null); - } - - protected ActionButton actionButton(String label, QueryAction action, @Nullable String parameterToAdd, @Nullable String parameterValue) - { - ActionURL url = urlFor(action); - if (url == null) - { - return null; - } - if (parameterToAdd != null) - url.addParameter(parameterToAdd, parameterValue); - return new ActionButton(label, url); - } - - protected String param(QueryParam param) - { - return param(param.toString()); - } - - protected String param(String param) - { - return getDataRegionName() + "." + param; - } - - protected URLHelper urlRefreshQuery() - { - URLHelper ret = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - ret = ret.clone(); - ret.deleteParameter(param(QueryParam.queryName)); - ret.deleteParameter(param(QueryParam.viewName)); - ret.deleteParameter(param(QueryParam.reportId)); - for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) - { - ret.deleteFilterParameters(key); - } - return ret; - } - - protected ActionURL urlBaseView() - { - ActionURL ret = getSettings().getSortFilterURL(); - for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) - { - ret.deleteFilterParameters(key); - } - ret.deleteParameter(DataRegion.LAST_FILTER_PARAM); - return ret; - } - - protected URLHelper urlChangeView() - { - URLHelper ret = getSettings().getReturnUrlHelper(); - if (null == ret) - { - ret = getSettings().getSortFilterURL(); - } - else if (getSettings().getDataRegionName() != null) - { - ret = ret.clone(); - // if we are using a returnUrl for this QV, make sure we apply any sort and filter - // parameters so that reports stay in sync with the data region. - URLHelper url = getSettings().getSortFilterURL(); - for (String param : url.getKeysByPrefix(getSettings().getDataRegionName())) - { - ret.replaceParameter(param, url.getParameter(param)); - } - } - else - { - ret = ret.clone(); - } - - ret.deleteParameter(param(QueryParam.viewName)); - ret.deleteParameter(param(QueryParam.reportId)); - ret.deleteParameter(RunReportView.CACHE_PARAM); - ret.deleteParameter(RunReportView.TAB_PARAM); - return ret; - } - - protected void addParamsByPrefix(ActionURL target, ActionURL source, String oldPrefix, String newPrefix) - { - for (String key : source.getKeysByPrefix(oldPrefix)) - { - String suffix = key.substring(oldPrefix.length()); - String newKey = newPrefix + suffix; - for (String value : source.getParameterValues(key)) - { - boolean isQueryParam = false; - try - { - Enum.valueOf(QueryParam.class, suffix); - isQueryParam = true; - } - catch (Exception ignore) { } - - if (suffix.equals("sort")) - { - // Prepend source sort parameter before target's existing sort - String targetSort = target.getParameter(key); - if (targetSort != null && !targetSort.isEmpty()) - value = value + "," + targetSort; - target.replaceParameter(newKey, value); - } - else if (isQueryParam) - { - // Issue 20779: Error: Query 'Containers,Containers' in schema 'core' doesn't exist - // Issue 21101: Cannot export QueryWebPart views using a custom sql query to Excel file - // Only a single non-empty value is accepted for query parameters -- overwrite the existing parameter so we don't have duplicate parameters. - if (value != null && !value.isEmpty()) - target.replaceParameter(newKey, value); - } - else - { - target.addParameter(newKey, value); - } - } - } - } - - protected boolean canInsert() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), InsertPermission.class) && table.getUpdateService() != null; - } - - protected boolean canUpdate() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), UpdatePermission.class) && table.getUpdateService() != null; - } - - protected boolean canDelete() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), DeletePermission.class); - } - - protected boolean isAdmin() - { - TableInfo table = getTable(); - return table != null && table.hasPermission(getUser(), AdminPermission.class); - } - - private boolean allowQueryTableInsertURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasInsertURLOverride() && table.allowQueryTableURLOverrides(); - } - - protected boolean allowQueryTableUpdateURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasUpdateURLOverride() && table.allowQueryTableURLOverrides(); - } - - private boolean allowQueryTableDeleteURLOverride() - { - TableInfo table = getTable(); - return table != null && table.hasDeleteURLOverride() && table.allowQueryTableURLOverrides(); - } - - public boolean showInsertNewButton() - { - return _showInsertNewButton; - } - - public void setShowInsertNewButton(boolean showInsertNewButton) - { - _showInsertNewButton = showInsertNewButton; - } - - public boolean showImportDataButton() - { - return _showImportDataButton; - } - - public void setShowImportDataButton(boolean show) - { - _showImportDataButton = show; - } - - public boolean showDeleteButton() - { - return _showDeleteButton; - } - - public void setShowDeleteButton(boolean showDeleteButton) - { - _showDeleteButton = showDeleteButton; - } - - public boolean showDeleteButtonConfirmationText() - { - return _showDeleteButtonConfirmationText; - } - - public void setShowDeleteButtonConfirmationText(boolean showDeleteButtonConfirmationText) - { - _showDeleteButtonConfirmationText = showDeleteButtonConfirmationText; - } - - public boolean showRecordSelectors() - { - return _showRecordSelectors; - } - - /** - * Show record selectors usually doesn't need to be explicitly set. If the ButtonBar contains - * a button that requires selection, the record selectors will be added. - */ - public void setShowRecordSelectors(boolean showRecordSelectors) - { - _showRecordSelectors = showRecordSelectors; - } - - protected void populateReportButtonBar(ButtonBar bar) - { - MenuButton queryButton = createQueryPickerButton("Query"); - queryButton.setVisible(getSettings().getAllowChooseQuery()); - bar.add(queryButton); - - if (getSettings().getAllowChooseView()) - { - bar.add(createViewButton(_itemFilter)); - populateChartsReports(bar); - } - - if (showExportButtons()) - { - ActionButton b = createPrintButton(); - if (null != b) - bar.add(b); - } - } - - protected void populateButtonBar(DataView view, ButtonBar bar) - { - MenuButton queryButton = createQueryPickerButton("Query"); - queryButton.setVisible(getSettings().getAllowChooseQuery()); - bar.add(queryButton); - - if (getSettings().getAllowChooseView()) - { - bar.add(createViewButton(_itemFilter)); - } - - populateChartsReports(bar); - - if ((canInsert() || allowQueryTableInsertURLOverride()) && (showInsertNewButton() || showImportDataButton())) - { - bar.add(createInsertMenuButton()); - } - - if ((canDelete() || allowQueryTableDeleteURLOverride()) && showDeleteButton()) - { - bar.add(createDeleteButton()); - } - - if (showExportButtons()) - { - List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); - - PanelButton b = createExportButton(recordSelectorColumns); - if (b.hasSubPanels()) - { - // Issue 24530: Add record selectors for exporting selected items. Assumes that all export panels support selection. - if ((recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || (getTable() != null && !getTable().getPkColumns().isEmpty())) - { - bar.setAlwaysShowRecordSelectors(true); - } - bar.add(b); - } - - ActionButton rs = createExportToRStudioButton(); - if (null != rs) - bar.add(rs); - } - } - - @Nullable ActionButton createExportToRStudioButton() - { - ActionButton rstudio = new ActionButton("RStudio"); - String script = DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleButtonPanel('export','rstudio'); return false;"; - rstudio.setScript(script, false); - rstudio.setVisible(showRStudioButton()); - rstudio.setEnabled(_hasExportRStudioPanel); - rstudio.setDisplayPermission(ReadPermission.class); - return rstudio; - } - - @Nullable - public ActionButton createEditMultipleButton() - { - ActionButton btn = null; - ActionURL editMultipleURL = urlFor(QueryAction.updateQueryRows); - if (editMultipleURL != null) - { - editMultipleURL.addParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY, _settings.getSelectionKey()); - btn = new ActionButton(editMultipleURL, "Edit Multiple"); - btn.setActionType(ActionButton.Action.POST); - btn.setDisplayPermission(UpdatePermission.class); - btn.setRequiresSelection(true, 2, null); - } - return btn; - } - - @Nullable - public ActionButton createDeleteButton() - { - return createDeleteButton(showDeleteButtonConfirmationText()); - } - - public ActionButton createDeleteButton(boolean showConfirmation) - { - ActionURL urlDelete = urlFor(QueryAction.deleteQueryRows); - if (urlDelete != null) - { - ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); - btnDelete.setIconCls("trash"); - btnDelete.setActionType(ActionButton.Action.POST); - btnDelete.setDisplayPermission(DeletePermission.class); - if (showConfirmation) - btnDelete.setRequiresSelection(true, "Are you sure you want to delete the selected row?", "Are you sure you want to delete the selected rows?"); - else - btnDelete.setRequiresSelection(true); - return btnDelete; - } - return null; - } - - public ActionButton createInsertMenuButton() - { - return createInsertMenuButton(null, null); - } - - public ActionButton createInsertMenuButton(ActionURL overrideInsertUrl, ActionURL overrideImportUrl) - { - MenuButton button = new MenuButton("Insert"); - button.setTooltip(getInsertButtonText(INSERT_DATA_TEXT)); - button.setIconCls("plus"); - boolean hasInsertNewOption = false; - boolean hasImportDataOption = false; - - if (showInsertNewButton()) - { - ActionURL urlInsert = overrideInsertUrl == null ? urlFor(QueryAction.insertQueryRow) : overrideInsertUrl; - if (urlInsert != null) - { - NavTree insertNew = new NavTree(getInsertButtonText(getInsertButtonText(INSERT_ROW_TEXT)), urlInsert); - button.addMenuItem(insertNew); - hasInsertNewOption = true; - } - } - - if (showImportDataButton()) - { - ActionURL urlImport = overrideImportUrl == null ? urlFor(QueryAction.importData) : overrideImportUrl; - if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) - { - NavTree importData = new NavTree(getInsertButtonText(IMPORT_BULK_DATA_TEXT), urlImport); - button.addMenuItem(importData); - hasImportDataOption = true; - } - } - - return hasInsertNewOption && hasImportDataOption? button : hasInsertNewOption ? createInsertButton() : hasImportDataOption ? createImportButton() : null; - } - - public ActionButton createInsertButton() - { - ActionURL urlInsert = urlFor(QueryAction.insertQueryRow); - if (urlInsert != null) - { - ActionButton btnInsert = new ActionButton(urlInsert, getInsertButtonText(INSERT_ROW_TEXT)); - btnInsert.setActionType(ActionButton.Action.LINK); - btnInsert.setTooltip(getInsertButtonText(INSERT_ROW_TEXT)); - btnInsert.setIconCls("plus"); - return btnInsert; - } - return null; - } - - public ActionButton createImportButton() - { - ActionURL urlImport = urlFor(QueryAction.importData); - if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) - { - ActionButton btnInsert = new ActionButton(urlImport, getInsertButtonText(IMPORT_BULK_DATA_TEXT)); - btnInsert.setActionType(ActionButton.Action.LINK); - btnInsert.setTooltip(getInsertButtonText(IMPORT_BULK_DATA_TEXT)); - btnInsert.setIconCls("plus"); - return btnInsert; - } - return null; - } - - protected String getInsertButtonText(String btnTxt) - { - return StringUtils.capitalize(btnTxt.toLowerCase()); - } - - @Nullable - protected ActionButton createPrintButton() - { - ActionButton btnPrint = actionButton("Print", QueryAction.printRows); - if (null == btnPrint) - return null; - btnPrint.setIconCls("print"); - btnPrint.setTarget("_blank"); - return btnPrint; - } - - private ActionButton createShareButton(@NotNull ActionURL url, @Nullable String tooltip) - { - ActionButton shareBtn = new ActionButton(url, "Share"); - shareBtn.setActionType(ActionButton.Action.LINK); - shareBtn.setIconCls("share"); - if (tooltip != null) - shareBtn.setTooltip(tooltip); - - return shareBtn; - } - - /** - * Make all links rendered in columns target the specified browser window/tab - */ - public void setLinkTarget(String linkTarget) - { - _linkTarget = linkTarget; - } - - public abstract static class ExportOptionsBean - { - private final String _dataRegionName; - private final String _exportRegionName; - private final String _selectionKey; - private final ColumnHeaderType _headerType; - private final boolean _includeSignButton; - private final String _email; - - protected ExportOptionsBean(String dataRegionName, String exportRegionName, @Nullable String selectionKey, - ColumnHeaderType headerType, boolean includeSignButton, @Nullable String email) - { - _dataRegionName = dataRegionName; - _exportRegionName = exportRegionName; - _selectionKey = selectionKey; - _headerType = headerType; - _includeSignButton = includeSignButton; - _email = email; - } - - public String getDataRegionName() - { - return _dataRegionName; - } - - public String getExportRegionName() - { - return _exportRegionName; - } - - @Nullable - public String getSelectionKey() - { - return _selectionKey; - } - - /** @return false if the region won't support row selectors, usually because it doesn't have a primary key */ - public boolean isSelectable() - { - return _selectionKey != null; - } - - public boolean hasSelected(ViewContext context) - { - if (!isSelectable()) - { - return false; - } - Set selected = DataRegionSelection.getSelected(context, _selectionKey, false); - return !selected.isEmpty(); - } - - public ColumnHeaderType getHeaderType() - { - return _headerType; - } - - public boolean isIncludeSignButton() - { - return _includeSignButton; - } - - public String getEmail() - { - return _email; - } - } - - public static class ExcelExportOptionsBean extends ExportOptionsBean - { - private final ActionURL _xlsURL; - private final ActionURL _xlsxURL; - private final ActionURL _iqyURL; - private final ActionURL _signXlsURL; - private final ActionURL _signXlsxURL; - - public ExcelExportOptionsBean( - String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, - ActionURL xlsURL, ActionURL xlsxURL, ActionURL iqyURL, ActionURL signXlsURL, ActionURL signXlsxURL, @Nullable String email) - { - super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signXlsURL && null != signXlsxURL), email); - _xlsURL = xlsURL; - _xlsxURL = xlsxURL; - _iqyURL = iqyURL; - _signXlsURL = null != signXlsURL ? signXlsURL : new ActionURL(); - _signXlsxURL = null != signXlsxURL ? signXlsxURL : new ActionURL(); - } - - @NotNull - public ActionURL getXlsxURL() - { - return _xlsxURL; - } - - public ActionURL getIqyURL() - { - return _iqyURL; - } - - @NotNull - public ActionURL getXlsURL() - { - return _xlsURL; - } - - @NotNull - public ActionURL getSignXlsURL() - { - return _signXlsURL; - } - - @NotNull - public ActionURL getSignXlsxURL() - { - return _signXlsxURL; - } - } - - public static class TextExportOptionsBean extends ExportOptionsBean - { - private final ActionURL _tsvURL; - private final ActionURL _signTsvURL; - - public TextExportOptionsBean( - String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, - ActionURL tsvURL, ActionURL signTsvURL, @Nullable String email) - { - super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signTsvURL), email); - _tsvURL = tsvURL; - _signTsvURL = null != signTsvURL ? signTsvURL : new ActionURL(); - } - - @NotNull - public ActionURL getTsvURL() - { - return _tsvURL; - } - - @NotNull - public ActionURL getSignTsvURL() - { - return _signTsvURL; - } - } - - @NotNull - public PanelButton createExportButton(@Nullable List recordSelectorColumns) - { - String buttonText = "Export"; - ActionURL signRowsXlsURL = null; - ActionURL signRowsXlsxURL = null; - ActionURL signRowsTsvURL = null; - ComplianceService complianceService = ComplianceService.get(); - if (complianceService.hasElecSignPermission(getContainer(), getUser()) && !getUser().isImpersonated()) - { - // We build a URL using Query's mechanism because it does a lot of work to get the properties right; - // Then build our URL to the ComplianceController using those properties. If any fail, just bail on creating button. - signRowsXlsURL = complianceService.urlFor(getContainer(), QueryAction.signRowsExcel, urlFor(QueryAction.signRowsExcel)); - signRowsXlsxURL = complianceService.urlFor(getContainer(), QueryAction.signRowsXLSX, urlFor(QueryAction.signRowsXLSX)); - signRowsTsvURL = complianceService.urlFor(getContainer(), QueryAction.signRowsTsv, urlFor(QueryAction.signRowsTsv)); - if (null != signRowsXlsURL && null != signRowsXlsxURL && null != signRowsTsvURL) - buttonText += " / Sign Data"; - } - - PanelButton button = new PanelButton("export", buttonText, getDataRegionName()); - button.setActionName("export"); // #32594: API can set a buttonConfig including "export"; since the caption may differ, add action so BuiltinButtonConfig can figure it out - ActionURL xlsURL = urlFor(QueryAction.exportRowsExcel); - ActionURL xlsxURL = urlFor(QueryAction.exportRowsXLSX); - ActionURL tsvURL = urlFor(QueryAction.exportRowsTsv); - - button.setIconCls("download"); - button.setTabAlignTop(true); - boolean hasRecordSelectors = (recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || - (getTable() != null && !getTable().getPkColumns().isEmpty()); - - if (xlsURL != null && xlsxURL != null) - { - ExcelExportOptionsBean excelBean = new ExcelExportOptionsBean( - getDataRegionName(), - getExportRegionName(), - hasRecordSelectors ? getSettings().getSelectionKey() : null, - getColumnHeaderType(), - xlsURL, - xlsxURL, - _allowExportExternalQuery ? urlFor(QueryAction.excelWebQueryDefinition) : null, - signRowsXlsURL, - signRowsXlsxURL, - getUser().getEmail() - ); - button.addSubPanel("Excel", new JspView<>("/org/labkey/api/query/excelExportOptions.jsp", excelBean)); - } - - if (tsvURL != null) - { - TextExportOptionsBean textBean = new TextExportOptionsBean( - getDataRegionName(), - getExportRegionName(), - hasRecordSelectors ? getSettings().getSelectionKey() : null, - getColumnHeaderType(), - tsvURL, - signRowsTsvURL, - getUser().getEmail() - ); - button.addSubPanel("Text", new JspView<>("/org/labkey/api/query/textExportOptions.jsp", textBean)); - } - - if (_allowExportExternalQuery) - { - addExportScriptItems(button); - addExportRStudio(button, hasRecordSelectors ? getSettings().getSelectionKey() : null); - } - - return button; - } - - - public void addExportRStudio(PanelButton exportButton, String selectionKey) - { - RStudioService rss = RStudioService.get(); - if (null == rss || null == rss.getRStudioLink(getUser(), getContainer())) - return; - if (null == getExportScriptFactory("r")) - return; - ActionURL exportUrl = urlFor(QueryAction.exportScript); - if (null == exportUrl) - return; - exportUrl.replaceParameter("scriptType","r"); - TextExportOptionsBean textBean = new TextExportOptionsBean(getDataRegionName(), getExportRegionName(), selectionKey, - getColumnHeaderType(), exportUrl, null, null); - HttpView exportView = rss.getExportToRStudioView(textBean); - if (exportView == null) - return; - exportButton.addSubPanel("RStudio", exportView); - _hasExportRStudioPanel = true; - } - - - public void addExportScriptItems(PanelButton button) - { - if (!_exportScriptFactories.isEmpty()) - { - Map options = new LinkedHashMap<>(); - - for (ExportScriptFactory factory : _exportScriptFactories.values()) - { - ActionURL url = urlFor(QueryAction.exportScript); - if (null != url) - { - url.addParameter("scriptType", factory.getScriptType()); - options.put(factory.getMenuText(), url); - } - } - - if (!options.isEmpty()) - button.addSubPanel("Script", new JspView<>("/org/labkey/api/query/scriptExportOptions.jsp", options)); - } - } - - public ReportService.ItemFilter getViewItemFilter() - { - return _itemFilter; - } - - public void setViewItemFilter(ReportService.ItemFilter filter) - { - if (filter != null) - _itemFilter = filter; - } - - public MenuButton createViewButton(ReportService.ItemFilter filter) - { - setViewItemFilter(filter); - String current = null; - - // if we are not rendering a report or not showing reports, we use the current view name to set the menu item - // selection, an empty string denotes the default view, a customized default view will have a null name. - if (_report == null || !_showReports) - current = (_customView != null) ? Objects.toString(_customView.getName(), "") : ""; - - URLHelper target = urlChangeView(); - MenuButton button = new MenuButton("Grid Views"); - button.setTooltip("Grid views"); - button.setIconCls("table"); - NavTree menu = button.getNavTree(); - - if (getSettings().isAllowCustomizeView()) - addCustomizeViewItems(button); - - if (!getQueryDef().isTemporary()) - { - button.addSeparator(); - addGridViews(button, target, current); - button.addSeparator(); - addManageViewItems(button, PageFlowUtil.map( - "schemaName", getSchema().getSchemaName(), - "queryName", getSettings().getQueryName())); - addFilterItems(button); - } - - return button; - } - - protected MenuButton createReportButton() - { - MenuButton button = new MenuButton("Reports"); - NavTree menu = button.getNavTree(); - - if (!getQueryDef().isTemporary() && _report == null) - { - List reportDesigners = new ArrayList<>(); - getSettings().setSchemaName(getSchema().getSchemaName()); - - for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) - { - for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) - { - if (designerInfo.getType() != ReportService.DesignerType.VISUALIZATION) - reportDesigners.add(designerInfo); - } - } - - reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); - - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (ReportService.DesignerInfo designer : reportDesigners) - { - if (viewItemFilter.accept(designer.getReportType(), designer.getLabel())) - { - NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); - item.setImageSrc(designer.getIconURL()); - item.setImageCls(designer.getIconCls()); - - menu.addChild(item); - } - } - } - - // existing reports - if (!getQueryDef().isTemporary()) - { - addReportViews(button); - } - - return button; - } - - private MenuButton createChartButton() - { - MenuButton button = new MenuButton("Charts"); - button.setIconCls("area-chart"); - - if (!getQueryDef().isTemporary() && _report == null) - { - List reportDesigners = new ArrayList<>(); - getSettings().setSchemaName(getSchema().getSchemaName()); - - for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) - { - for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) - { - if (designerInfo.getType() == ReportService.DesignerType.VISUALIZATION) - reportDesigners.add(designerInfo); - } - } - - reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); - - for (ReportService.DesignerInfo designer : reportDesigners) - { - NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); - item.setImageSrc(designer.getIconURL()); - item.setImageCls(designer.getIconCls()); - button.addMenuItem(item); - } - } - - if (!getQueryDef().isTemporary()) - { - addChartViews(button); - } - - return button; - } - - protected void populateChartsReports(ButtonBar bar) - { - if (isShowReports()) - { - MenuButton reportButton = createReportButton(); - MenuButton chartButton = createChartButton(); - NavTree uiProviderLinks = createUIProviderLinks(); - - if (reportButton.getNavTree().hasChildren()) - { - chartButton.setTooltip("Charts / Reports"); - NavTree chartMenu = chartButton.getNavTree(); - chartMenu.addSeparator(); - for (NavTree child : reportButton.getNavTree().getChildren()) - chartButton.addMenuItem(child); - } - if (uiProviderLinks != null && uiProviderLinks.hasChildren()) - { - chartButton.addSeparator(); - for (NavTree child : uiProviderLinks.getChildren()) - chartButton.addMenuItem(child); - } - - if (chartButton.getNavTree().hasChildren()) - bar.add(chartButton); - } - } - - private NavTree createUIProviderLinks() - { - NavTree menu = null; - List uiProviders = ReportService.get().getUIProviders(); - Map> uiProviderAddedViews = new TreeMap<>(); - - for (ReportService.UIProvider provider : uiProviders) - { - for (Pair additionalItem : provider.getAdditionalChartingMenuItems(getViewContext(), getSettings())) - { - if (!uiProviderAddedViews.containsKey(additionalItem.second)) - uiProviderAddedViews.put(additionalItem.second, new ArrayList<>()); - uiProviderAddedViews.get(additionalItem.second).add(additionalItem.first); - } - } - - if (!uiProviderAddedViews.isEmpty()) - { - menu = new NavTree(); - for (Map.Entry> entry : uiProviderAddedViews.entrySet()) - { - List navItems = entry.getValue(); - navItems.sort(Comparator.comparing(NavTree::getText)); - for (NavTree item : navItems) - menu.addChild(item); - } - } - - return menu; - } - - public ReportService.ItemFilter getItemFilter() - { - QueryDefinition def = QueryService.get().getQueryDef(getUser(), getContainer(), getSchema().getSchemaName(), getSettings().getQueryName()); - if (def == null) - def = QueryService.get().createQueryDefForTable(getSchema(), getSettings().getQueryName()); - - return new WrappedItemFilter(_itemFilter, def); - } - - private static class WrappedItemFilter implements ReportService.ItemFilter - { - private final ReportService.ItemFilter _filter; - private final Map _filterItemMap = new HashMap<>(); - - - public WrappedItemFilter(ReportService.ItemFilter filter, QueryDefinition def) - { - _filter = filter; - - if (def != null) - { - for (ViewOptions.ViewFilterItem item : def.getViewOptions().getViewFilterItems()) - _filterItemMap.put(item.getViewType(), item); - } - } - - @Override - public boolean accept(String type, String label) - { - if (_filter.accept(type, label)) - { - if (_filterItemMap.containsKey(type)) - return _filterItemMap.get(type).isEnabled(); - else - return true; - } - - if (_filterItemMap.containsKey(type)) - return _filterItemMap.get(type).isEnabled(); - - return false; - } - } - - protected void addFilterItems(MenuButton button) - { - if (_customView != null && _customView.hasFilterOrSort()) - { - URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - url = url.clone(); - NavTree item; - String label = "Apply Grid Filter"; - if (ignoreUserFilter()) - { - url.deleteParameter(param(QueryParam.ignoreFilter)); - item = new NavTree(label, url); - } - else - { - url.replaceParameter(param(QueryParam.ignoreFilter), "1"); - item = new NavTree(label, url); - item.setSelected(true); - } - item.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".clearSelected({quiet: true});"); - button.addMenuItem(item); - } - - TableInfo t = getTable(); - if (t instanceof UnionTable ut) - { - t = ut.getComponentTable(); // check against a component table - } - if (null != t && t.supportsContainerFilter() && !getAllowableContainerFilterTypes().isEmpty()) - { - NavTree containerFilterItem = new NavTree("Folder Filter"); - button.addMenuItem(containerFilterItem); - - ContainerFilter selectedFilter = getContainerFilter(); - ContainerFilter.Type selectedFilterType = null != selectedFilter ? selectedFilter.getType() : ContainerFilter.Type.Current; - - for (ContainerFilter.Type filterType : getAllowableContainerFilterTypes()) - { - URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); - url = url.clone(); - String propName = getDataRegionName() + DataRegion.CONTAINER_FILTER_NAME; - url.replaceParameter(propName, filterType.name()); - NavTree filterItem = new NavTree(filterType.toString(), url); - - if (selectedFilterType == filterType) - { - filterItem.setSelected(true); - } - filterItem.setNoFollow(true); - containerFilterItem.addChild(filterItem); - } - } - } - - protected String getChangeViewScript(String viewName) - { - return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'view', viewName:" + PageFlowUtil.jsString(viewName) + "});"; - } - - protected String getChangeReportScript(String reportId) - { - return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'report', reportId:" + PageFlowUtil.jsString(reportId) + "});"; - } - - protected void addGridViews(MenuButton menu, URLHelper target, String currentView) - { - List views = new ArrayList<>(getQueryDef().getCustomViews(getViewContext().getUser(), getViewContext().getRequest(), false, false).values()); - List viewItems = new ArrayList<>(); - - // default grid view stays at the top level. The default will have a getName == null - boolean hasDefault = false; - for (CustomView view : views) - { - if (view.getName() == null) - { - hasDefault = true; - break; - } - } - - // To make generating menu items easier, create a default custom view if it doesn't exist yet. - if (!hasDefault) - { - // don't pass getUser() as owner, we want the default view to appear as "public" - CustomView defaultView = getQueryDef().createCustomView(); - views.add(0, defaultView); - } - - // sort the grid view alphabetically, with default first (null name), then private views over public ones - views.sort((o1, o2) -> - { - if (o1.getName() == null) return -1; - if (o2.getName() == null) return 1; - if (!o1.isShared() && o2.isShared()) return -1; - if (o1.isShared() && !o2.isShared()) return 1; - - return o1.getName().compareToIgnoreCase(o2.getName()); - }); - - for (CustomView view : views) - { - if (view.isHidden()) - continue; - - NavTree item; - String name = view.getName(); - if (name == null) - { - String label = Objects.toString(view.getLabel(), "Default"); - - item = new NavTree(label, (ActionURL) null); - item.setScript(getChangeViewScript("")); - if ("".equals(currentView)) - item.setStrong(true); - } - else - { - String label = view.getLabel(); - - item = new NavTree(label, (ActionURL) null); - item.setScript(getChangeViewScript(name)); - if (name.equals(currentView)) - item.setStrong(true); - } - - StringBuilder description = new StringBuilder(); - if (view.isSession()) - { - item.setEmphasis(true); - description.append("Unsaved "); - } - if (view.isShared()) - description.append("Shared "); - else - description.append("Private "); - - if (view.getContainer() != null && !view.getContainer().equals(getContainer())) - description.append("Inherited from '").append(PageFlowUtil.filter(view.getContainer().getPath())).append("'"); - - if (!description.isEmpty()) - item.setDescription(description.toString()); - - try - { - URLHelper iconUrl; - if (null != view.getCustomIconUrl()) - iconUrl = new URLHelper(view.getCustomIconUrl()); - else - iconUrl = new URLHelper(view.isShared() ? "/reports/grid.gif" : "/reports/icon_private_view.png"); - iconUrl.setContextPath(AppProps.getInstance().getParsedContextPath()); - item.setImageSrc(iconUrl); - - if (null != view.getCustomIconCls()) - item.setImageCls(view.getCustomIconCls()); - } - catch (URISyntaxException e) - { - _log.error("Invalid custom view icon url", e); - } - - viewItems.add(item); - menu.addMenuItem(item); - } - - // enable menu filtering for the module list if > 10 items - if (viewItems.size() > 10) - { - String menuFilterItemCls = PopupMenuView.getMenuFilterItemCls(menu.getNavTree()); - for (NavTree item : viewItems) - item.setMenuFilterItemCls(menuFilterItemCls); - } - - } - - protected void addReportViews(MenuButton menu) - { - List allReports = new ArrayList<>(); - // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too - for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) - { - allReports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); - } - Map> views = new TreeMap<>(); - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (Report report : allReports) - { - // Filter out reports that don't match what this view is supposed to show. This can prevent - // reports that were created on the same schema and table/query from a different view from showing up on a - // view that's doing magic to add additional filters, for example. - if (viewItemFilter.accept(report.getType(), null) - && !report.getType().equals(TimeChartReport.TYPE) - && !report.getType().equals(GenericChartReport.TYPE)) - { - if (canViewReport(getUser(), getContainer(), report) && !report.getDescriptor().isHidden()) - { - if (!views.containsKey(report.getType())) - views.put(report.getType(), new ArrayList<>()); - - views.get(report.getType()).add(report); - } - } - } - - if (!views.isEmpty()) - menu.addSeparator(); - - for (Map.Entry> entry : views.entrySet()) - { - List reports = entry.getValue(); - - // sort the list of reports within each type grouping - reports.sort((o1, o2) -> - { - String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); - String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); - - return n1.compareToIgnoreCase(n2); - }); - - for (Report report : reports) - { - String reportId = report.getDescriptor().getReportId().toString(); - NavTree item = new NavTree(report.getDescriptor().getReportName(), (ActionURL) null); - if (report.getDescriptor().getReportId().equals(getSettings().getReportId())) - item.setStrong(true); - item.setImageSrc(ReportUtil.getIconUrl(getContainer(), report)); - item.setScript(getChangeReportScript(reportId)); - menu.addMenuItem(item); - } - } - } - - protected void addChartViews(MenuButton menu) - { - List reports = new ArrayList<>(); - // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too - for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) - { - reports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); - } - Map> views = new TreeMap<>(); - ReportService.ItemFilter viewItemFilter = getItemFilter(); - - for (Report report : reports) - { - // Filter out reports that don't match what this view is supposed to show. This can prevent - // reports that were created on the same schema and table/query from a different view from showing up on a - // view that's doing magic to add additional filters, for example. - if (viewItemFilter.accept(report.getType(), null) && - (report.getType().equals(TimeChartReport.TYPE) || report.getType().equals(GenericChartReport.TYPE))) - { - if (canViewReport(getUser(), getContainer(), report)) - { - if (!views.containsKey(report.getType())) - views.put(report.getType(), new ArrayList<>()); - - views.get(report.getType()).add(report); - } - } - } - - if (!views.isEmpty()) - menu.addSeparator(); - - for (Map.Entry> entry : views.entrySet()) - { - List charts = entry.getValue(); - - charts.sort((o1, o2) -> - { - String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); - String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); - - return n1.compareToIgnoreCase(n2); - }); - - for (Report chart : charts) - { - String chartId = chart.getDescriptor().getReportId().toString(); - NavTree item = new NavTree(chart.getDescriptor().getReportName(), (ActionURL) null); - item.setImageSrc(ReportUtil.getIconUrl(getContainer(), chart)); - item.setImageCls(ReportUtil.getIconCls(chart)); - item.setScript(getChangeReportScript(chartId)); - - if (chart.getDescriptor().getReportId().equals(getSettings().getReportId())) - item.setStrong(true); - - menu.addMenuItem(item); - } - } - } - - protected boolean canViewReport(User user, Container c, Report report) - { - return true; - } - - public void addCustomizeViewItems(MenuButton button) - { - if (_report == null) - { - ActionURL urlTableInfo = getSchema().urlFor(QueryAction.tableInfo); - urlTableInfo.addParameter(QueryParam.queryName.toString(), getQueryDef().getName()); - - NavTree customizeView = new NavTree("Customize Grid"); - customizeView.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleShowCustomizeView();"); - customizeView.setImageCls("fa fa-pencil"); - button.addMenuItem(customizeView); - } - - if (isAdmin() && QueryService.get().isQuerySnapshot(getContainer(), getSchema().getSchemaName(), getSettings().getQueryName())) - { - QuerySnapshotService.Provider provider = QuerySnapshotService.get(getSchema().getSchemaName()); - if (provider != null) - { - NavTree item = button.addMenuItem("Edit Snapshot", provider.getEditSnapshotURL(getSettings(), getViewContext())); - } - } - } - - public void addManageViewItems(MenuButton button, Map params) - { - ActionURL url = PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(getContainer()); - for (Map.Entry entry : params.entrySet()) - url.addParameter(entry.getKey(), entry.getValue()); - - NavTree item = button.addMenuItem("Manage Views", url); - item.setImageCls("fa fa-cog"); - } - - public String getDataRegionName() - { - return getSettings().getDataRegionName(); - } - - private String getExportRegionName() - { - return _useQueryViewActionExportURLs ? getDataRegionName() : DATAREGIONNAME_DEFAULT; - } - - private String _baseId = null; - - /** - * Use this html encoded dataRegionName as the base id for menus and attribute values that need to be rendered into the DOM. - */ - protected String getBaseMenuId() - { - if (_baseId == null) - _baseId = PageFlowUtil.filter(getDataRegionName()); - return _baseId; - } - - protected String h(Object o) - { - return PageFlowUtil.filter(o); - } - - /** - * this is the choke point for rendering reports and views, if this method is overridden you need to call - * super in order to have report/view rendering to work properly. - */ - @Override - protected void renderView(Object model, HttpServletRequest request, HttpServletResponse response) throws Exception - { - if (isReportView(getViewContext())) - renderReportView(request, response); - else - renderDataRegion(HtmlWriter.of(response)); - } - - private void renderReportView(HttpServletRequest request, HttpServletResponse response) throws IOException - { - if (_report != null) - { - try - { - ReportDataRegion dr = new ReportDataRegion(getSettings(), getViewContext(), _report); - RenderContext ctx = new RenderContext(getViewContext()); - - if (!isPrintView()) - { - // not sure why this is necessary (adding the reportId to the context) - ctx.put("reportId", _report.getDescriptor().getReportId()); - - ButtonBar bar = new ButtonBar(); - populateReportButtonBar(bar); - - if (_report.allowShareButton(getUser(), getContainer())) - { - ActionURL shareUrl = PageFlowUtil.urlProvider(ReportUrls.class).urlShareReport(getContainer(), _report); - if (shareUrl != null) - bar.add(createShareButton(shareUrl, "Share report")); - } - - dr.setButtonBar(bar); - } - dr.render(ctx, request, response); - - // if the user is viewing a shared report, remove any notifications related to it - NotificationService.get().removeNotifications( - getContainer(), _report.getDescriptor().getReportId().toString(), - Collections.singletonList(Report.SHARE_REPORT_TYPE), getUser().getUserId() - ); - } - catch (Exception e) - { - renderErrors(HtmlWriter.of(response), "Error rendering report : " + _report.getDescriptor().getReportName(), Collections.singletonList(e)); - } - } - } - - protected SqlDialect getSqlDialect() - { - return getSchema().getDbSchema().getSqlDialect(); - } - - protected DataRegion createDataRegion() - { - DataRegion rgn = new DataRegion(); - configureDataRegion(rgn); - return rgn; - } - - protected void configureDataRegion(DataRegion rgn) - { - rgn.setDisplayColumns(getDisplayColumns()); - rgn.setSettings(getSettings()); - rgn.setShowRecordSelectors(showRecordSelectors()); - rgn.setSelectAllURL(urlFor(QueryAction.selectAll)); - - rgn.setShadeAlternatingRows(isShadeAlternatingRows()); - rgn.setShowFilterDescription(isShowFilterDescription()); - rgn.setShowBorders(isShowBorders()); - rgn.setShowSurroundingBorder(isShowSurroundingBorder()); - rgn.setShowPagination(isShowPagination()); - rgn.setShowPaginationCount(isShowPaginationCount()); - - if (_messageSupplier != null) - rgn.addMessageSupplier(_messageSupplier); - - if (_customView != null && _customView.getErrors() != null) - { - rgn.addMessageSupplier(dataRegion -> _customView.getErrors().stream() - .map(e -> new DataRegion.Message(e, DataRegion.MessageType.ERROR, DataRegion.MessagePart.view)) - .collect(Collectors.toList())); - } - - TableInfo table = getTable(); - if (table instanceof FilteredTable ft && ft.hasRulesOmittedColumns()) - { - rgn.addMessageSupplier(x -> List.of(new DataRegion.Message("PHI protected columns have been omitted", DataRegion.MessageType.WARNING, DataRegion.MessagePart.header))); - } - - // Allow region to specify header lock, optionally override - if (rgn.getAllowHeaderLock()) - rgn.setAllowHeaderLock(getSettings().getAllowHeaderLock()); - - rgn.setTable(table); - - if (isShowConfiguredButtons()) - { - // We first apply the button bar config from the table: - ButtonBarConfig tableBarConfig = table == null ? null : table.getButtonBarConfig(); - if (tableBarConfig != null) - rgn.addButtonBarConfig(tableBarConfig); - // Then any overriding button bar config (from javascript) is applied: - if (_buttonBarConfig != null) - rgn.addButtonBarConfig(_buttonBarConfig); - } - - if (table != null && table.getAggregateRowConfig() != null) - { - rgn.setAggregateRowConfig(table.getAggregateRowConfig()); - } - } - - public void setButtonBarPosition(DataRegion.ButtonBarPosition buttonBarPosition) - { - _buttonBarPosition = buttonBarPosition; - } - - public void setButtonBarConfig(ButtonBarConfig buttonBarConfig) - { - _buttonBarConfig = buttonBarConfig; - } - - public ButtonBarConfig getButtonBarConfig() - { - return _buttonBarConfig; - } - - private boolean isReportView(ViewContext viewContext) - { - _report = getSettings().getReportView(viewContext); - - return _report != null && StringUtils.trimToNull(getSettings().getViewName()) == null; - } - - public DataView createDataView() - { - DataRegion rgn = createDataRegion(); - - //if explicit set of fieldkeys has been set - //add those specifically to the region - if (null != getSettings().getFieldKeys()) - { - TableInfo table = getTable(); - if (table != null) - { - rgn.clearColumns(); - List keys = getSettings().getFieldKeys(); - FieldKey starKey = FieldKey.fromParts("*"); - - // include details and update columns if they've been requested - addDetailsAndUpdateColumns(rgn.getDisplayColumns(), table); - - //special-case: if one of the keys is *, add all columns from the - //TableInfo and remove the * so that Query doesn't choke on it - if (keys.contains(starKey)) - { - rgn.addColumns(table.getColumns()); - keys.remove(starKey); - // Since the client requested all columns, don't filter which ones get sent back - getSettings().setFieldKeys(null); - } - - if (!keys.isEmpty()) - { - Map selectedCols = QueryService.get().getColumns(table, keys); - for (ColumnInfo col : selectedCols.values()) - rgn.addColumn(col); - } - } - } - else if (null != getSettings().getExtraFieldKeys()) - { - TableInfo table = getTable(); - if (table != null) - { - List keys = getSettings().getExtraFieldKeys(); - if (!keys.isEmpty()) - { - Map selectedCols = QueryService.get().getColumns(table, keys); - for (ColumnInfo col : selectedCols.values()) - rgn.addColumn(col); - } - } - } - - GridView ret = new GridView(rgn, _errors); - setupDataView(ret); - return ret; - } - - protected void setupDataView(DataView ret) - { - DataRegion rgn = ret.getDataRegion(); - ret.setFrame(WebPartView.FrameType.NONE); - rgn.setAllowAsync(true); - ButtonBar bb = new ButtonBar(); - if (!(isApiResponseView() || isPrintView() || isExportView())) - { - populateButtonBar(ret, bb); - - // TODO: Until the "More" menu is dynamically populated the "Print" button has been moved back to the bar. - // Print button is rendered separately to respect ordering -- we want it rendering after all custom buttons - // added by overrides of populateButtonBar(). - // bar.add(populateMoreMenu()); - if (showExportButtons()) - bb.add(createPrintButton()); - } - rgn.setButtonBar(bb); - - rgn.setButtonBarPosition(isApiResponseView() || isPrintView() ? DataRegion.ButtonBarPosition.NONE : _buttonBarPosition); - - if (getSettings() != null && getSettings().getShowRows() == ShowRows.ALL) - { - // Don't cache if the ResultSet is likely to be very large - ret.getRenderContext().setCache(false); - } - - ActionURL customViewUrl = null; - if (_customView != null && _customView.hasFilterOrSort() && !ignoreViewFilter()) - { - customViewUrl = new ActionURL(); - _customView.applyFilterAndSortToURL(customViewUrl, getDataRegionName()); - } - - // Apply base sorts and filters from custom view and from QuerySettings. - if (!ignoreUserFilter()) - { - SimpleFilter filter; - if (ret.getRenderContext().getBaseFilter() instanceof SimpleFilter) - { - filter = (SimpleFilter) ret.getRenderContext().getBaseFilter(); - } - else - { - filter = new SimpleFilter(ret.getRenderContext().getBaseFilter()); - } - Sort sort = ret.getRenderContext().getBaseSort(); - if (sort == null) - { - sort = new Sort(); - } - - // We need to set the base sort/filter _before_ adding the customView sort/filter. - // If the user has set a sort on their custom view, we want their sort to take precedence. - filter.addAllClauses(getSettings().getBaseFilter()); - sort.insertSort(getSettings().getBaseSort()); - - if (customViewUrl != null) - { - try - { - filter.addUrlFilters(customViewUrl, getDataRegionName()); - } - catch (ConversionException e) - { - _errors.reject(ERROR_MSG, "Invalid grid view filter: " + e.getMessage()); - } - sort.addURLSort(customViewUrl, getDataRegionName()); - } - - ret.getRenderContext().setBaseFilter(filter); - ret.getRenderContext().setBaseSort(sort); - } - - // Apply analytics providers from custom view and query settings - List analyticsProviders = new LinkedList<>(); - if (ret.getRenderContext().getBaseAnalyticsProviders() != null) - analyticsProviders.addAll(ret.getRenderContext().getBaseAnalyticsProviders()); - if (getSettings().getAnalyticsProviders() != null) - analyticsProviders.addAll(getSettings().getAnalyticsProviders()); - if (customViewUrl != null) - analyticsProviders.addAll(AnalyticsProviderItem.fromURL(customViewUrl, getDataRegionName())); - ret.getRenderContext().setBaseAnalyticsProviders(analyticsProviders); - - // XXX: Move to QuerySettings? - if (_customView != null) - ret.getRenderContext().setView(_customView); - - // TODO: Don't set available container filters in render context - // 11082: Need to push list of available container filters to DataRegion.js - ret.getRenderContext().put("allowableContainerFilterTypes", getAllowableContainerFilterTypes()); - } - - - protected void renderDataRegion(HtmlWriter out) throws Exception - { - // make sure table has been instantiated - getTable(); - List errors = getParseErrors(); - if (errors.isEmpty()) - { - include(createDataView(), out.unwrap()); - } - else - { - renderErrors(out, "Query '" + getQueryDef().getName() + "' has errors", errors); - } - } - - - protected ColumnHeaderType getColumnHeaderType() - { - return ColumnHeaderType.Caption; - } - - public TSVGridWriter getTsvWriter() throws IOException - { - return getTsvWriter(getColumnHeaderType()); - } - - protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType) throws IOException - { - return getTsvWriter(headerType, Collections.emptyMap()); - } - - protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType, @NotNull Map renameColumnMap) - { - _exportView = true; - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setAllowAsync(false); - rgn.setShowPagination(false); - rgn.prepareDisplayColumns(getContainer()); - RenderContext rc = view.getRenderContext(); - rc.setCache(false); - TSVGridWriter tsv = new TSVGridWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), renameColumnMap); - tsv.setFilenamePrefix(getSettings().getQueryName() != null ? getSettings().getQueryName() : "query"); - // don't step on default - if (null != headerType) - tsv.setColumnHeaderType(headerType); - return tsv; - } - - public Results getResults() throws SQLException, IOException - { - return getResults(ShowRows.ALL); - } - - public Results getResults(ShowRows showRows) throws SQLException, IOException - { - return getResults(showRows, false, false); - } - - public Results getResults(ShowRows showRows, boolean async, boolean cache) throws SQLException, IOException - { - _exportView = true; - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - ShowRows prevShowRows = getSettings().getShowRows(); - try - { - // Set to the desired row policy - getSettings().setShowRows(showRows); - rgn.setAllowAsync(async); - view.getRenderContext().setCache(cache); - RenderContext ctx = view.getRenderContext(); - if (null == rgn.getResults(ctx)) - return null; - return new ResultsImpl(ctx); - } - finally - { - // We have to reset the show-rows setting, since we don't know what's going to be done with this - // queryview after the call to 'getResults'. It's possible it could still be rendered to the client, - // as happens with study datasets. - getSettings().setShowRows(prevShowRows); - } - } - - - @Nullable - public ResultSet getResultSet() throws SQLException, IOException - { - Results r = getResults(); - return r == null ? null : r.getResultSet(); - } - - - public List getExportColumns(List list) - { - List ret = new ArrayList<>(list); - ret.removeIf(next -> next instanceof DetailsColumn || next instanceof UpdateColumn); - return ret; - } - - public final ExcelWriter getExcelWriter(@NotNull ExcelExportConfig config) throws IOException - { - // Call the appropriate overridden method - ExcelWriter ew = getExcelWriter(config.getDocType(), null); - return configureExcelWriter(ew, config); - } - - public ExcelWriter getExcelWriter(ExcelWriter.ExcelDocumentType docType, @Nullable Map renameColumnMap) throws IOException - { - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - RenderContext rc = configureForExcelExport(docType, view, rgn); - - ExcelWriter ew = new ExcelWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), docType, renameColumnMap); - - ew.setFilenamePrefix(getSettings().getQueryName()); - ew.setAutoSize(true); - return ew; - } - - /** - * Sets configuration settings for the provided ExcelWriter according the provided config and this QueryView - * @param excelWriter to configure (CALLER TO CLOSE) - * @param config additional properties to set on the writer - */ - public ExcelWriter configureExcelWriter(ExcelWriter excelWriter, ExcelExportConfig config) - { - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - RenderContext rc = configureForExcelExport(excelWriter.getDocumentType(), view, rgn); - rgn.prepareDisplayColumns(view.getViewContext().getContainer()); - rgn.setAllowAsync(false); - excelWriter.setDisplayColumns(getExportColumns(rgn.getDisplayColumns())); - excelWriter.setResultsFactory(()->rgn.getResults(rc)); - excelWriter.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); - excelWriter.setRenameColumnMap(config.getRenamedColumns()); - excelWriter.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); - excelWriter.setAutoSize(true); - - return excelWriter; - } - - protected ExcelWriter getExcelTemplateWriter(@NotNull ExcelExportConfig config) - { - // The template should be based on the actual columns in the table, not the user's default view, - // which may be hiding columns or showing values joined through lookups - - //NOTE: if the the user passed a viewName param on the URL, we will use these columns - //with the caveat that we will skip and non-user editable columns or those that do - //map to fields in this table (ie. lookups). we will also append any missing - //required columns. - - //TODO: the latter might be problematic if the value of required column is set - //in a validation script. however, the dev could always set it to userEditable=false or nullable=true - List fieldKeys = new ArrayList<>(20); - TableInfo t = createTable(); - - if (!config.getRespectView()) - { - for (ColumnInfo columnInfo : t.getColumns()) - { - FieldKey fieldKey = columnInfo.getFieldKey(); - // Issue 43760: "isUserEditable" does not mean what you think it means. UniqueIdFields must be marked as "UserEditable" - // in order to show up in a details view, but then that makes them show up in the export, where they shouldn't. Booo. - if (config.getIncludeColumns().contains(fieldKey) || (columnInfo.isUserEditable() && !columnInfo.isUniqueIdField())) - { - fieldKeys.add(fieldKey); - } - } - - // Add remaining includeCols to the end - for (FieldKey includeCol : config.getIncludeColumns()) - { - if (!fieldKeys.contains(includeCol)) - fieldKeys.add(includeCol); - } - - } - else - { - // get list of required columns so we can verify presence - Set requiredCols = new HashSet<>(config.getIncludeColumns()); - for (ColumnInfo c : t.getColumns()) - { - if (c.inferIsShownInInsertView()) - requiredCols.add(c.getFieldKey()); - } - - - for (FieldKey key : getCustomView().getColumns()) - { - if (key.getParent() != null) - continue; - - if (requiredCols.contains(key)) - { - fieldKeys.add(key); - requiredCols.remove(key); - continue; - } - - Map cols = QueryService.get().getColumns(t, Collections.singleton(key)); - ColumnInfo col = cols.get(key); - if (col != null && col.isUserEditable()) - { - fieldKeys.add(key); - requiredCols.remove(key); - } - } - - // Add any remaining required columns to the end - fieldKeys.addAll(requiredCols); - } - - List displayColumns = getExcelTemplateDisplayColumns(fieldKeys); - return new ExcelWriter(()->null, displayColumns, config.getDocType(), config.getRenamedColumns()); - } - - protected List getExcelTemplateDisplayColumns(List fieldKeys) - { - // Force the view to use our special list - getSettings().setFieldKeys(fieldKeys); - - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setAllowAsync(false); - rgn.setShowPagination(false); - - // Add explicitly requested columns, even if they don't actually exist on the table. - // They may be magic columns supported on the import side, e.g. "MaterialsInputs/Foo" for SampleTypes. - List displayColumns = rgn.getDisplayColumns(); - Set displayColumnFieldKeys = displayColumns.stream() - .map(DisplayColumn::getColumnInfo) - .filter(Objects::nonNull) - .map(ColumnInfo::getFieldKey) - .collect(Collectors.toSet()); - - for (FieldKey fieldKey : fieldKeys) - { - if (!displayColumnFieldKeys.contains(fieldKey)) - { - DisplayColumn dc = new SimpleDisplayColumn(); - dc.setName(fieldKey.getName()); - displayColumns.add(dc); - } - } - - displayColumns = getExportColumns(displayColumns); - - // Need to remove special MV columns - displayColumns.removeIf(col -> col.getColumnInfo() instanceof RawValueColumn); - - return displayColumns; - } - - protected RenderContext configureForExcelExport(ExcelWriter.ExcelDocumentType docType, DataView view, DataRegion rgn) - { - if (getSettings().getShowRows() == ShowRows.ALL) - { - // Limit the rows returned based on the document type. - // The maxRows setting isn't used unless showRows is PAGINATED. - getSettings().setShowRows(ShowRows.PAGINATED); - getSettings().setMaxRows(docType.getMaxRows()); - } - getSettings().setOffset(Table.NO_OFFSET); - rgn.prepareDisplayColumns(view.getViewContext().getContainer()); // Prep the display columns to translate generic date/time formats, see #21094 - rgn.setAllowAsync(false); - RenderContext rc = view.getRenderContext(); - // Cache resultset only for SAS/SHARE data sources. See #12966 (which removed caching) and #13638 (which added it back for SAS) - boolean sas = "SAS".equals(rgn.getTable().getSqlDialect().getProductName()); - rc.setCache(sas); - return rc; - } - - public static class ExcelExportConfig - { - private HttpServletResponse response; - private ColumnHeaderType headerType; - private Workbook workbook = null; - private ExcelWriter.ExcelDocumentType docType = ExcelWriter.ExcelDocumentType.xlsx; - private Map renamedColumns = new HashMap<>(); - private boolean templateOnly = false; - private boolean insertColumnsOnly = false; - private boolean respectView = false; - private List includeColumns = Collections.emptyList(); - private List excludeColumns = Collections.emptyList(); - private String prefix = null; - - public ExcelExportConfig(HttpServletResponse response, ColumnHeaderType headerType) - { - this.response = response; - this.headerType = headerType; - } - - public ExcelExportConfig setPrefix(String prefix) - { - this.prefix = prefix; - return this; - } - - public String getPrefix() - { - return this.prefix; - } - - public ExcelExportConfig setExcludeColumns(List excludeColumns) - { - this.excludeColumns = excludeColumns; - return this; - } - - public List getExcludeColumns() - { - return this.excludeColumns; - } - - public ExcelExportConfig setIncludeColumns(List includeColumns) - { - this.includeColumns = includeColumns; - return this; - } - - public List getIncludeColumns() - { - return this.includeColumns; - } - - public ExcelExportConfig setRespectView(boolean respectView) - { - this.respectView = respectView; - return this; - } - - public boolean getRespectView() - { - return this.respectView; - } - - public ExcelExportConfig setInsertColumnsOnly(boolean insertColumnsOnly) - { - this.insertColumnsOnly = insertColumnsOnly; - return this; - } - - public boolean getInsertColumnsOnly() - { - return this.insertColumnsOnly; - } - - public ExcelExportConfig setHeaderType(ColumnHeaderType headerType) - { - this.headerType = headerType; - return this; - } - - public ColumnHeaderType getHeaderType() - { - return this.headerType; - } - - public ExcelExportConfig setTemplateOnly(boolean templateOnly) - { - this.templateOnly = templateOnly; - return this; - } - - public boolean getTemplateOnly() - { - return this.templateOnly; - } - - public ExcelExportConfig setRenamedColumns(Map renamedColumns) - { - this.renamedColumns = renamedColumns; - return this; - } - - public Map getRenamedColumns() - { - return this.renamedColumns; - } - - public ExcelExportConfig setDocType(ExcelWriter.ExcelDocumentType docType) - { - this.docType = docType; - return this; - } - - public ExcelWriter.ExcelDocumentType getDocType() - { - return this.docType; - } - - public ExcelExportConfig setResponse(HttpServletResponse response) - { - this.response = response; - return this; - } - - public @NotNull HttpServletResponse getResponse() - { - return this.response; - } - public ExcelExportConfig setWorkbook(Workbook workbook) - { - this.workbook = workbook; - return this; - } - - public @Nullable Workbook getWorkbook() - { - return this.workbook; - } - } - - public void exportToExcel(HttpServletResponse response) throws IOException - { - exportToExcel(new ExcelExportConfig(response, getColumnHeaderType())); - } - - public void exportToExcel(HttpServletResponse response, Workbook workbook) throws IOException - { - exportToExcel(new ExcelExportConfig(response, getColumnHeaderType()).setWorkbook(workbook)); - } - - public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType) throws IOException - { - exportToExcel(new ExcelExportConfig(response, headerType).setDocType(docType)); - } - - public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType, @NotNull Map renameColumn) throws IOException - { - exportToExcel( - new ExcelExportConfig(response, headerType) - .setDocType(docType) - .setRenamedColumns(renameColumn) - ); - } - - public void exportToExcel(ExcelExportConfig config) throws IOException - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - ExcelWriter ew = config.getTemplateOnly() ? getExcelTemplateWriter(config) : getExcelWriter(config); - ew.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); - ew.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); - if (config.getPrefix() != null) - ew.setFilenamePrefix(config.getPrefix()); - ew.setAutoSize(true); - ew.renderWorkbook(config.getResponse()); - - if (!config.getTemplateOnly()) - logAuditEvent("Exported to Excel", ew.getDataRowCount()); - } - } - - @Nullable - public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, @Nullable Map metadata, - @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - return exportToExcelFile(docType, getColumnHeaderType(), metadata, rowsOut, includeTimestamp); - } - - @Nullable - public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, ColumnHeaderType headerType, @Nullable Map metadata, - @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); - try (OutputStream stream = new BufferedOutputStream(byteStream)) - { - ExcelWriter ew = getExcelWriter(docType, null); - ew.setCaptionType(headerType); - ew.setShowInsertableColumnsOnly(false, null); - ew.setMetadata(metadata); - ew.renderWorkbook(stream); - String extension = docType.name(); - String filename = includeTimestamp ? - FileUtil.makeFileNameWithTimestamp(ew.getFilenamePrefix(), extension) : - ew.getFilenamePrefix() + "." + extension; - ByteArrayAttachmentFile byteArrayAttachmentFile = - new ByteArrayAttachmentFile(filename, byteStream.toByteArray(), docType.getMimeType()); - - if (null != rowsOut) - rowsOut.add(ew.getDataRowCount()); - logAuditEvent("Exported to Excel file", ew.getDataRowCount()); - return byteArrayAttachmentFile; - } - } - - return null; - } - - public void exportToTsv(HttpServletResponse response) throws IOException - { - exportToTsv(response, TSVWriter.DELIM.TAB, TSVWriter.QUOTE.DOUBLE, getColumnHeaderType()); - } - - public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType) throws IOException - { - exportToTsv(response, delim, quote, headerType, Collections.emptyMap()); - } - - public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException - { - _exportView = true; - TableInfo table = getTable(); - - if (table != null) - { - int rowCount = doExport(response, delim, quote, headerType, renameColumnMap); - logAuditEvent("Exported to TSV", rowCount); - } - } - - private int doExport(HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException - { - try (TSVGridWriter tsv = renameColumnMap.isEmpty() ? getTsvWriter(headerType) : getTsvWriter(headerType, renameColumnMap)) - { - tsv.setDelimiterCharacter(delim); - tsv.setQuoteCharacter(quote); - tsv.write(response); - return tsv.getDataRowCount(); - } - } - - @Nullable - public ByteArrayAttachmentFile exportToTsvFile(final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, - @Nullable List commentLines, @Nullable List rowsOut, boolean includeTimestamp) throws Exception - { - _exportView = true; - TableInfo table = getTable(); - if (table != null) - { - StringBuilder tsvBuilder = new StringBuilder(); - - try (TSVGridWriter tsvWriter = getTsvWriter(headerType)) - { - tsvWriter.setDelimiterCharacter(delim); - tsvWriter.setQuoteCharacter(quote); - if (null != commentLines) - tsvWriter.setFileHeader(commentLines); - tsvWriter.write(tsvBuilder); - String extension = delim.extension; - String filename = includeTimestamp ? - FileUtil.makeFileNameWithTimestamp(tsvWriter.getFilenamePrefix(), extension) : - tsvWriter.getFilenamePrefix() + "." + extension; - String contentType = delim.contentType; - ByteArrayAttachmentFile byteArrayAttachmentFile = new ByteArrayAttachmentFile(filename, tsvBuilder.toString().getBytes(StringUtilsLabKey.DEFAULT_CHARSET), contentType); - - if (null != rowsOut) - rowsOut.add(tsvWriter.getDataRowCount()); - logAuditEvent("Exported to TSV file", tsvWriter.getDataRowCount()); - return byteArrayAttachmentFile; - } - } - - return null; - } - - public void exportToApiResponse(ApiQueryResponse response) - { - TableInfo table = getTable(); - if (table != null) - { - _apiResponseView = true; - setShowDetailsColumn(response.isIncludeDetailsColumn()); - setShowUpdateColumn(response.isIncludeUpdateColumn()); - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - rgn.setShowPaginationCount(!response.isMetaDataOnly()); - - //force the pk column(s) into the default list of columns - List pkCols = table.getPkColumns(); - for (ColumnInfo pkCol : pkCols) - { - if (null == rgn.getDisplayColumn(pkCol.getName())) - rgn.addColumn(pkCol); - } - - RenderContext ctx = view.getRenderContext(); - rgn.setAllowAsync(false); - rgn.prepareDisplayColumns(ctx.getContainer()); - List displayColumns; - if (response.isIncludeDetailsColumn() || response.isIncludeUpdateColumn()) - displayColumns = rgn.getDisplayColumns(); - else - displayColumns = getExportColumns(rgn.getDisplayColumns()); - response.initialize(ctx, rgn, table, displayColumns); - } - else - { - //table was null--try to get parse errors - List errors = getParseErrors(); - if (null != errors && !errors.isEmpty()) - throw errors.get(0); - } - } - - public void exportToExcelWebQuery(HttpServletResponse response) throws Exception - { - TableInfo table = getTable(); - if (null == table) - return; - - DataView view = createDataView(); - DataRegion rgn = view.getDataRegion(); - - // Backwards compatibility for export URLs that don't specify a showRows value, see issue 24523 - if (getViewContext().getRequest().getParameter(getSettings().getDataRegionName() + ".showRows") == null) - { - getSettings().setShowRows(ShowRows.ALL); - } - - // We're not sure if we're dealing with a version of Excel that can handle more than 65535 rows. - // Assume that it can, and rely on the fact that Excel throws out rows if there are more than it can handle - RenderContext ctx = configureForExcelExport(ExcelWriter.ExcelDocumentType.xlsx, view, rgn); - - Results results = rgn.getResults(ctx); - - // Bug 5610 & 6179. Excel web queries don't work over SSL if caching is disabled, - // so we need to allow caching so that Excel can read from IE on Windows. - // Set the headers to allow the client to cache, but not proxies - ResponseHelper.setPrivate(response); - - HtmlExportWriter writer = new HtmlExportWriter(); - writer.write(results, getExportColumns(rgn.getDisplayColumns()), response, ctx, true); - - logAuditEvent("Exported to Excel Web Query data", writer.getDataRowCount()); - } - - /** - * Mark all rows in the query view as selected in the user's session. - */ - public int selectAll() throws IOException - { - if (StringUtils.isEmpty(getSelectionKey())) - throw new IllegalStateException(); - - TableInfo table = getTable(); - if (table == null) - throw new IllegalStateException(); - - return DataRegionSelection.setSelectionForAll(this, this.getSelectionKey(), true); - } - - public void logAuditEvent(String comment, int dataRowCount) - { - QueryService.get().addAuditEvent(this, comment, dataRowCount); - } - - public CustomView getCustomView() - { - return _customView; - } - - public void setCustomView(CustomView customView) - { - _customView = customView; - } - - public void setCustomView(String viewName) - { - _settings.setViewName(viewName); - _customView = _settings.getCustomView(getViewContext(), getQueryDef()); - } - - protected TableInfo createTable() - { - QueryDefinition qdef = getQueryDef(); - if (null == qdef) - return null; - qdef.setContainerFilter(getContainerFilter()); - return qdef.getTable(_schema, _parseErrors, true); - } - - final public TableInfo getTable() - { - // We'll have parseErrors if we already tried and failed to create the table - if (_table != null || !_parseErrors.isEmpty()) - return _table; - _table = createTable(); - - /* TODO ContainerFilter check that this is correct for hasUnionTable() */ - if (_table instanceof ContainerFilterable && _table.supportsContainerFilter()) - { - ContainerFilter filter = getContainerFilter(); - if (filter != null) - { - // If table has a Union version, apply the filter to the Union - UserSchema userSchema = _table.getUserSchema(); - if (ContainerFilter.Type.Current != filter.getType() && null != userSchema && _table.hasUnionTable()) - { - Set containers = new HashSet<>(); - if (ContainerFilter.Type.AllFolders != filter.getType()) - { - Collection containerIds = filter.getIds(); - if (null != containerIds) - { - for (GUID id : containerIds) - containers.add(ContainerManager.getForId(id)); - } - } - else - { - containers = ContainerManager.getAllChildren(ContainerManager.getRoot()); - } - - if (!containers.isEmpty()) - _table = userSchema.getUnionTable(_table, containers); - } - } - } - - return _table; - } - - // This can be used to override the container filter that would otherwise be provided by the QuerySettings - ContainerFilter _overrideContainerFilter = null; - - public void setContainerFilter(ContainerFilter cf) - { - _overrideContainerFilter = cf; - } - - @Nullable - protected ContainerFilter getContainerFilter() - { - if (null != _overrideContainerFilter) - return _overrideContainerFilter; - - String filterName = _settings.getContainerFilterName(); - - if (filterName == null && _customView != null) - filterName = _customView.getContainerFilterName(); - - if (filterName != null) - return ContainerFilter.getContainerFilterByName(filterName, getContainer(), getUser()); - - return null; - } - - private boolean isShowExperimentalGenericDetailsURL() - { - return AppProps.getInstance().isOptionalFeatureEnabled(EXPERIMENTAL_GENERIC_DETAILS_URL); - } - - - List _queryDefDisplayColumns = null; - - public List getDisplayColumns() - { - TableInfo table = getTable(); - if (table == null) - return Collections.emptyList(); - - List ret = new ArrayList<>(); - addDetailsAndUpdateColumns(ret, table); - - if (null == _queryDefDisplayColumns) - _queryDefDisplayColumns = getQueryDef().getDisplayColumns(_customView, table); - ret.addAll(_queryDefDisplayColumns); - - if (_linkTarget != null) - { - for (DisplayColumn displayColumn : ret) - { - displayColumn.setLinkTarget(_linkTarget); - } - } - return ret; - } - - protected void addDetailsAndUpdateColumns(List ret, TableInfo table) - { - // Print view and export view don't need details and update columns, - // but the selectRows API can turn them on to include the URLs in the response format. - if (isPrintView() || isExportView()) - return; - - if (_showDetailsColumn && (null != _detailsURL || table.hasDetailsURL() || isShowExperimentalGenericDetailsURL())) - { - StringExpression urlDetails = urlExpr(QueryAction.detailsQueryRow); - - if (urlDetails != null && urlDetails != AbstractTableInfo.LINK_DISABLER) - { - // We'll decide at render time if we have enough columns in the results to make the DetailsColumn visible - DisplayColumn dc = createDetailsColumn(urlDetails, table); - if (null != dc) - ret.add(dc); - } - } - - if (_showUpdateColumn && (canUpdate() || allowQueryTableUpdateURLOverride())) - { - StringExpression urlUpdate = urlExpr(QueryAction.updateQueryRow); - if (urlUpdate != null) - { - DisplayColumn dc = createUpdateColumn(urlUpdate, table); - if (null != dc) - ret.add(0, dc); - } - } - } - - /** - * The intent of this method is to ensure that the update/details URL inherit the - * ContainerContext from the table unless explicitly set. This is relevant because QWPs can - * supply custom update/detailsURLs as a string, which has no ContainerContext. Most TableInfos - * always set the ContainerContext on the details/update URLs to ContainerContext.FieldKeyContext, - * which delegates the container to row-level (usually based on a container column). - */ - private void ensureUrlContainerContext(StringExpression se, TableInfo table) - { - if (se instanceof DetailsURL du) - { - if (!du.hasContainerContext()) - { - du.setContainerContext(table.getContainerContext()); - } - } - } - - @Nullable - protected DisplayColumn createDetailsColumn(StringExpression urlDetails, TableInfo table) - { - ensureUrlContainerContext(urlDetails, table); - - return new DetailsColumn(urlDetails, table); - } - - protected DisplayColumn createUpdateColumn(StringExpression urlUpdate, TableInfo table) - { - ensureUrlContainerContext(urlUpdate, table); - - return new UpdateColumn.Impl(urlUpdate); - } - - public QueryDefinition getQueryDef() - { - return _queryDef; - } - - public List getParseErrors() - { - return _parseErrors; - } - - public NavTrailConfig getNavTrailConfig() - { - NavTrailConfig ret = new NavTrailConfig(getRootContext()); - ret.setExtraChildren(new NavTree(getSchema().getSchemaName() + " queries", getSchema().urlFor(QueryAction.begin))); - return ret; - } - - public void setShowExportButtons(boolean showExportButtons) - { - _showExportButtons = showExportButtons; - } - - public boolean showExportButtons() - { - return _showExportButtons; - } - - public boolean showRStudioButton() - { - return _showRStudioButton; - } - - /** Currently requires showExportButtons(), or button will not be enabled */ - public void setShowRStudioButton(boolean showRStudioButton) - { - _showRStudioButton = showRStudioButton; - } - - public void setShowDetailsColumn(boolean showDetailsColumn) - { - _showDetailsColumn = showDetailsColumn; - } - - public void setShowUpdateColumn(boolean showUpdateColumn) - { - _showUpdateColumn = showUpdateColumn; - } - - public void setUpdateURL(String updateURL) - { - _updateURL = null==updateURL ? null : DetailsURL.fromString(updateURL); - } - - public void setUpdateURL(DetailsURL updateURL) - { - _updateURL = updateURL; - } - - public void setDetailsURL(String detailsURL) - { - _detailsURL = null==detailsURL ? null : DetailsURL.fromString(detailsURL); - } - - public void setDetailsURL(DetailsURL detailsURL) - { - _detailsURL = detailsURL; - } - - public void setDeleteURL(String deleteURL) - { - _deleteURL = deleteURL; - } - - public void setInsertURL(String insertURL) - { - _insertURL = insertURL; - } - - public void setImportURL(String importURL) - { - _importURL = importURL; - } - - public void setPrintView(boolean b) - { - _printView = b; - } - - public boolean isPrintView() - { - return _printView; - } - - public boolean isExportView() - { - return _exportView; - } - - public boolean isApiResponseView() - { - return _apiResponseView; - } - - public void setApiResponseView(boolean apiResponseView) - { - _apiResponseView = apiResponseView; - } - - public boolean isUseQueryViewActionExportURLs() - { - return _useQueryViewActionExportURLs; - } - - public void setUseQueryViewActionExportURLs(boolean useQueryViewActionExportURLs) - { - _useQueryViewActionExportURLs = useQueryViewActionExportURLs; - } - - public boolean isAllowExportExternalQuery() - { - return _allowExportExternalQuery; - } - - public void setAllowExportExternalQuery(boolean allowExportExternalQuery) - { - _allowExportExternalQuery = allowExportExternalQuery; - } - - public boolean isShadeAlternatingRows() - { - return _shadeAlternatingRows; - } - - public void setShadeAlternatingRows(boolean shadeAlternatingRows) - { - _shadeAlternatingRows = shadeAlternatingRows; - } - - public boolean isShowFilterDescription() - { - return _showFilterDescription; - } - - public void setShowFilterDescription(boolean showFilterDescription) - { - _showFilterDescription = showFilterDescription; - } - - public boolean isShowBorders() - { - return _showBorders; - } - - public void setShowBorders(boolean showBorders) - { - _showBorders = showBorders; - } - - public boolean isShowSurroundingBorder() - { - return _showSurroundingBorder; - } - - public void setShowSurroundingBorder(boolean showSurroundingBorder) - { - _showSurroundingBorder = showSurroundingBorder; - } - - public boolean isShowPagination() - { - return _showPagination; - } - - public void setShowPagination(boolean showPagination) - { - _showPagination = showPagination; - } - - public boolean isShowPaginationCount() - { - return _showPaginationCount; - } - - public void setShowPaginationCount(boolean showPaginationCount) - { - _showPaginationCount = showPaginationCount; - } - - /** - * controls display of the reports and charts button - */ - public boolean isShowReports() - { - // buttons can be hidden either through query settings or method overriding - return _showReports && getSettings().isShowReports(); - } - - public void setShowReports(boolean showReports) - { - _showReports = showReports; - } - - public boolean isShowConfiguredButtons() - { - return _showConfiguredButtons; - } - - public void setShowConfiguredButtons(boolean showConfiguredButtons) - { - _showConfiguredButtons = showConfiguredButtons; - } - - @NotNull - public Set getAllowableContainerFilterTypes() - { - return _allowableContainerFilterTypes; - } - - public void setAllowableContainerFilterTypes(@NotNull Collection allowableContainerFilterTypes) - { - _allowableContainerFilterTypes = Collections.unmodifiableSet(new LinkedHashSet<>(allowableContainerFilterTypes)); - } - - public void setAllowableContainerFilterTypes(ContainerFilter.Type... allowableContainerFilterTypes) - { - setAllowableContainerFilterTypes(Arrays.asList(allowableContainerFilterTypes)); - } - - public void disableContainerFilterSelection() - { - _allowableContainerFilterTypes = Collections.emptySet(); - } - - public List getAnalyticsProviders() - { - return getSettings().getAnalyticsProviders(); - } - - @NotNull - @Override - public LinkedHashSet getClientDependencies() - { - LinkedHashSet resources = new LinkedHashSet<>(); - resources.addAll(super.getClientDependencies()); - - ButtonBarConfig cfg = _buttonBarConfig; - if (cfg == null) - { - TableInfo ti = _table; - if (ti == null) - { - List errors = new ArrayList<>(); - QueryDefinition queryDef = getQueryDef(); - if (queryDef != null) - { - if (null != getContainerFilter()) - queryDef.setContainerFilter(getContainerFilter()); - ti = queryDef.getTable(getSchema(), errors, true, false); - } - } - - if (ti != null) - cfg = ti.getButtonBarConfig(); - } - - if (cfg != null && cfg.getScriptIncludes() != null) - { - for (String script : cfg.getScriptIncludes()) - { - resources.add(ClientDependency.fromPath(script)); - } - } - - List displayColumns = getDisplayColumns(); - - if (null != displayColumns) - { - for (DisplayColumn dc : displayColumns) - { - resources.addAll(dc.getClientDependencies()); - } - } - - return resources; - } - - public void setMessageSupplier(DataRegion.MessageSupplier messageSupplier) - { - _messageSupplier = messageSupplier; - } -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.api.query; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.beanutils.ConversionException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.ss.usermodel.Workbook; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.action.ApiQueryResponse; +import org.labkey.api.admin.notification.NotificationService; +import org.labkey.api.attachments.ByteArrayAttachmentFile; +import org.labkey.api.compliance.ComplianceService; +import org.labkey.api.data.AbstractTableInfo; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.AnalyticsProviderItem; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ButtonBarConfig; +import org.labkey.api.data.ColumnHeaderType; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerFilterable; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DetailsColumn; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.ExcelWriter; +import org.labkey.api.data.HtmlExportWriter; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.PanelButton; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.Results; +import org.labkey.api.data.ResultsImpl; +import org.labkey.api.data.ShowRows; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Sort; +import org.labkey.api.data.TSVGridWriter; +import org.labkey.api.data.TSVWriter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.UpdateColumn; +import org.labkey.api.data.dialect.SqlDialect; +import org.labkey.api.exp.RawValueColumn; +import org.labkey.api.query.snapshot.QuerySnapshotService; +import org.labkey.api.reports.Report; +import org.labkey.api.reports.ReportService; +import org.labkey.api.reports.report.ReportUrls; +import org.labkey.api.reports.report.r.RReport; +import org.labkey.api.reports.report.view.ReportUtil; +import org.labkey.api.reports.report.view.RunReportView; +import org.labkey.api.reports.report.view.ScriptReportBean; +import org.labkey.api.rstudio.RStudioService; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.ResourceURL; +import org.labkey.api.study.UnionTable; +import org.labkey.api.study.reports.CrosstabReport; +import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.HtmlString; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.ResponseHelper; +import org.labkey.api.util.StringExpression; +import org.labkey.api.util.StringExpressionFactory; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.GridView; +import org.labkey.api.view.HttpView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTrailConfig; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.PopupMenuView; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.ViewServlet; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.ClientDependency; +import org.labkey.api.visualization.GenericChartReport; +import org.labkey.api.visualization.TimeChartReport; +import org.labkey.api.writer.ContainerUser; +import org.labkey.api.writer.HtmlWriter; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URISyntaxException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.stream.Collectors; + +import static org.labkey.api.action.SpringActionController.ERROR_MSG; +import static org.labkey.api.util.DOM.P; +import static org.labkey.api.util.DOM.cl; + +/** + * View that generates the majority of standard data grids/tables in the LabKey Server UI. + * The backing query is lazily invoked when it comes time to render the QueryView. + */ +public class QueryView extends WebPartView implements ContainerUser +{ + public static final String EXPERIMENTAL_GENERIC_DETAILS_URL = "generic-details-url"; + + public static final String EXCEL_WEB_QUERY_EXPORT_TYPE = "excelWebQuery"; + public static final String DATAREGIONNAME_DEFAULT = "query"; + + private static final Logger _log = LogManager.getLogger(QueryView.class); + private static final Map _exportScriptFactories = new ConcurrentSkipListMap<>(); + + protected static final String INSERT_DATA_TEXT = "Insert Data"; + protected static final String INSERT_ROW_TEXT = "Insert New Row"; + protected static final String IMPORT_BULK_DATA_TEXT = "Import Bulk Data"; + + protected DataRegion.ButtonBarPosition _buttonBarPosition = DataRegion.ButtonBarPosition.TOP; + private ButtonBarConfig _buttonBarConfig = null; + private boolean _showDetailsColumn = true; + private boolean _showUpdateColumn = true; + private DataRegion.MessageSupplier _messageSupplier; + + private String _linkTarget; + + // Overrides for any URLs that might already be set on the TableInfo + private DetailsURL _updateURL; + private DetailsURL _detailsURL; + private String _insertURL; + private String _importURL; + private String _deleteURL; + + private boolean _hasExportRStudioPanel = false; + + + public static void register(ExportScriptFactory factory) + { + register(factory, false); + } + + public static void register(ExportScriptFactory factory, boolean overrideBaseFactory) + { + if (!overrideBaseFactory) + assert null == _exportScriptFactories.get(factory.getScriptType()); + + _exportScriptFactories.put(factory.getScriptType(), factory); + } + + public static ExportScriptFactory getExportScriptFactory(String type) + { + return _exportScriptFactories.get(type); + } + + static public QueryView create(ViewContext context, UserSchema schema, QuerySettings settings, BindException errors) + { + return schema.createView(context, settings, errors); + } + + static public QueryView create(QueryForm form, BindException errors) + { + form.ensureSchemaExists(); + + return create(form.getViewContext(), form.getSchema(), form.getQuerySettings(), errors); + } + + private QueryDefinition _queryDef; + private CustomView _customView; + private UserSchema _schema; + private Errors _errors; + private final List _parseErrors = new ArrayList<>(); + private QuerySettings _settings; + private boolean _showRecordSelectors = false; + + private boolean _shadeAlternatingRows = true; + private boolean _showFilterDescription = true; + private boolean _showBorders = true; + private boolean _showSurroundingBorder = true; + private Report _report; + + private boolean _showExportButtons = true; + private boolean _showRStudioButton = false; // might want show by default if rstudio is configured + private boolean _showInsertNewButton = true; + private boolean _showImportDataButton = true; + private boolean _showDeleteButton = true; + private boolean _showDeleteButtonConfirmationText = true; + private boolean _showConfiguredButtons = true; + private boolean _allowExportExternalQuery = true; + + private static final Set STANDARD_CONTAINER_FILTERS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(ContainerFilter.Type.Current, ContainerFilter.Type.CurrentAndSubfolders, ContainerFilter.Type.AllFolders))); + + /** The container filters (called "Folder Filter" in the UI) that should be available to users in the Views menu */ + @NotNull + private Set _allowableContainerFilterTypes = STANDARD_CONTAINER_FILTERS; + private boolean _useQueryViewActionExportURLs = false; + private boolean _printView = false; + private boolean _exportView = false; + private boolean _apiResponseView = false; + private boolean _showPagination = true; + private boolean _showPaginationCount = true; + private boolean _showReports = true; + private ReportService.ItemFilter _itemFilter = DEFAULT_ITEM_FILTER; + + public static ReportService.ItemFilter DEFAULT_ITEM_FILTER = (type, label) -> + { + if (ReportService.get().getGlobalItemFilterTypes().contains(type)) return true; + if (RReport.TYPE.equals(type)) return true; + return CrosstabReport.TYPE.equals(type); + }; + + private TableInfo _table; + + public QueryView(QueryForm form, Errors errors) + { + this(form.getSchema(), form.getQuerySettings(), errors); + } + + + /** + * Must call setSettings before using the view + */ + public QueryView(UserSchema schema) + { + super(FrameType.DIV); + setSchema(schema); + } + + @Override + public void setTitle(CharSequence title) + { + super.setTitle(title); + if (StringUtils.isNotEmpty(title) && getFrame()==FrameType.DIV) + setFrame(FrameType.PORTAL); + } + + + /** Use the constructor that takes an Errors object instead */ + @Deprecated + protected QueryView(UserSchema schema, QuerySettings settings) + { + this(schema, settings, null); + } + + public QueryView(UserSchema schema, QuerySettings settings, @Nullable Errors errors) + { + this(schema); + // TODO: stop passing in null Errors. For now, new one up if null. + _errors = errors != null ? errors : new BindException(new Object(), "form"); + if (null != settings) + setSettings(settings); + } + + public QuerySettings getSettings() + { + return _settings; + } + + + protected void setSettings(QuerySettings settings) + { + if (null != _settings || null == _schema) + throw new IllegalStateException(); + _settings = settings; + _queryDef = settings.getQueryDef(_schema); + // Disable external exports (scripts, etc) since they will run in a different HTTP session that doesn't + // have access to the temporary query + if (_queryDef != null) + { + _allowExportExternalQuery &= !_queryDef.isTemporary(); + } + _customView = settings.getCustomView(getViewContext(), getQueryDef()); + } + + + protected int getMaxRows() + { + if (getShowRows() == ShowRows.NONE) + return Table.NO_ROWS; + if (getShowRows() != ShowRows.PAGINATED) + return Table.ALL_ROWS; + return getSettings().getMaxRows(); + } + + + protected long getOffset() + { + if (getShowRows() != ShowRows.PAGINATED) + return 0; + return getSettings().getOffset(); + } + + protected ShowRows getShowRows() + { + return getSettings().getShowRows(); + } + + protected String getSelectionKey() + { + return getSettings().getSelectionKey(); + } + + /** + * Returns an ActionURL for the "returnUrl" parameter or the current ActionURL if none. + */ + public URLHelper getReturnUrl() + { + return getSettings().getReturnUrlHelper(ViewServlet.getRequestURL()); + } + + protected boolean verboseErrors() + { + return true; + } + + + protected boolean ignoreUserFilter() + { + return (getViewContext().getRequest() != null && getViewContext().getRequest().getParameter(param(QueryParam.ignoreFilter)) != null) || + (getSettings() != null && getSettings().getIgnoreUserFilter()); + } + + // ignores filters on the custom view but not those added through query settings + protected boolean ignoreViewFilter() + { + return getSettings() != null && getSettings().getIgnoreViewFilter(); + } + + protected void renderErrors(HtmlWriter out, String message, List errors) + { + boolean isEditable = getQueryDef() != null && getQueryDef().canEdit(getUser()) && getQueryDef().isSqlEditable(); + P( + cl("labkey-error"), + message, + isEditable ? HtmlString.NBSP : null, + isEditable ? LinkBuilder.simpleLink("Edit Query", Objects.requireNonNull(getSchema().urlFor(QueryAction.sourceQuery, getQueryDef()))) : null + ).appendTo(out); + + Set seen = new HashSet<>(); + + if (verboseErrors()) + { + for (Throwable e : errors) + { + if (e instanceof QueryParseException) + { + out.write(e.getMessage()); + } + else + { + out.write(e.toString()); + } + + String resolveURL = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveURL); + if (null != resolveURL && seen.add(resolveURL)) + { + String resolveText = ExceptionUtil.getExceptionDecoration(e, ExceptionUtil.ExceptionInfo.ResolveText); + if (getUser().isPlatformDeveloper()) + { + out.write(" "); + out.write(LinkBuilder.labkeyLink(Objects.toString(resolveText, "resolve"), resolveURL)); + } + } + out.write(HtmlString.BR); + } + } + } + + /* delay load menu, because it is usually visible==false */ + private class QueryNavTreeMenuButton extends MenuButton + { + private boolean populated = false; + + QueryNavTreeMenuButton(String label) + { + super(label); + setVisible(false); + } + + @Override + public void setVisible(boolean visible) + { + if (visible && !populated) + { + populateMenu(); + populated = true; + } + super.setVisible(visible); + } + + private void populateMenu() + { + if (getQueryDef() != null) + { + NavTree editQueryItem; + if (getQueryDef().isSqlEditable() && getQueryDef().canEdit(getUser())) + editQueryItem = new NavTree("Edit Source", getSchema().urlFor(QueryAction.sourceQuery, getQueryDef())); + else + editQueryItem = new NavTree("View Definition", getSchema().urlFor(QueryAction.schemaBrowser, getQueryDef())); + addMenuItem(editQueryItem); + + if (getQueryDef().isMetadataEditable() && getQueryDef().canEditMetadata(getUser())) + { + NavTree editMetadataItem = new NavTree("Edit Metadata", getSchema().urlFor(QueryAction.metadataQuery, getQueryDef())); + addMenuItem(editMetadataItem); + } + } + + addSeparator(); + + if (getSchema().shouldRenderTableList()) + { + String current = getQueryDef() != null ? getQueryDef().getName() : null; + URLHelper target = urlRefreshQuery(); + + for (QueryDefinition query : getSchema().getTablesAndQueries(true)) + { + String name = query.getName(); + NavTree item = new NavTree(name, target.clone().replaceParameter(param(QueryParam.queryName), name)); + // Intentionally don't set the description so we can avoid having to instantiate all of the TableInfos, + // which can be expensive for some schemas + if (name.equals(current)) + item.setStrong(true); + item.setImageSrc(new ResourceURL("/reports/grid.gif")); + item.setImageCls("fa fa-table"); + addMenuItem(item); + } + } + else + { + ActionURL schemaBrowserURL = PageFlowUtil.urlProvider(QueryUrls.class).urlSchemaBrowser(getContainer(), getSchema().getName()); + addMenuItem("Schema Browser", schemaBrowserURL); + } + } + } + + + public MenuButton createQueryPickerButton(String label) + { + return new QueryNavTreeMenuButton(label); + } + + + @Override + public User getUser() + { + return _schema.getUser(); + } + + public UserSchema getSchema() + { + return _schema; + } + + protected void setSchema(UserSchema schema) + { + if (null != _settings || null != _schema) + throw new IllegalStateException(); + _schema = schema; + } + + @Override + public Container getContainer() + { + return _schema.getContainer(); + } + + protected StringExpression urlExpr(QueryAction action) + { + StringExpression expr = switch (action) + { + case detailsQueryRow -> _detailsURL; + case updateQueryRow -> _updateURL; + default -> null; + + // NOTE: details/update URL may not get picked up from TableInfo if subclass overrides createTable() + // but that case should use QueryView.setDetailsURL/setUpdateURL() anyway + }; + + if (null == expr) + expr = getQueryDef().urlExpr(action, _schema.getContainer()); + + if (expr == null) + return null; + + // Don't append the returnUrl parameter in API responses + if (!isApiResponseView()) + { + switch (action) + { + case detailsQueryRow: + case updateQueryRow: + case insertQueryRow: + case importData: + case updateQueryRows: + case deleteQueryRows: + { + // ICK + URLHelper returnUrl = getReturnUrl(); + if (returnUrl != null) + { + String encodedReturnURL = PageFlowUtil.encode(returnUrl.getLocalURIString()); + expr = ((StringExpressionFactory.AbstractStringExpression) expr).addParameter(ActionURL.Param.returnUrl.name(), encodedReturnURL); + } + } + } + } + + return expr; + } + + @Nullable + protected ActionURL urlFor(QueryAction action) + { + ActionURL ret = null; + switch (action) + { + case deleteQueryRows: + if (null != _deleteURL) + ret = DetailsURL.fromString(_deleteURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + case detailsQueryRow: + // TODO kinda suspect... since this is a per-row url + if (null != _detailsURL) + ret = _detailsURL.getActionURL(); + break; + case updateQueryRow: + // TODO also kinda suspect... + if (null != _updateURL) + ret = _updateURL.getActionURL(); + break; + case insertQueryRow: + if (null != _insertURL) + ret = DetailsURL.fromString(_insertURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + case importData: + if (null != _importURL) + ret = DetailsURL.fromString(_importURL).setContainerContext(_schema.getContainer()).getActionURL(); + break; + } + + if (null == ret && null != getQueryDef()) + ret = _schema.urlFor(action, getQueryDef()); + + if (ret == null) + { + return null; + } + + // Issue 11280: Export URLs don't include the query's base sort/filter. + // The solution is to expand the custom view's saved sort/filter before adding the base sort/filter. + // NOTE: This is a temporary solution. + // + // We won't need to expand the saved custom view filters or analyticsProviders. Filters can be applied + // in any order and the analyticsProviders don't make much sense in the exported xls or tsv files. + // + // The correct long term solution is to (a) create proper QueryView subclasses using UserSchema.createView() + // and (b) use POST instead of GET for the export actions (or others) to match the LABKEY.QueryWebPart config behavior. + // Using POST is necessary since the LABKEY.QueryWebPart config expresses other options (column lists, grid rendering options, etc) that can't be expressed on URLs. + // + // Issue 17313: Exporting from a grid should respect "Apply View Filter" state + if (_customView != null) + { + if (_customView.getName() != null) + ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.viewName, _customView.getName()); + + if (!ignoreUserFilter() && _customView != null && _customView.hasFilterOrSort()) + { + _customView.applyFilterAndSortToURL(ret, DATAREGIONNAME_DEFAULT); + } + } + + // Applying the base sort/filter to the url is lossy in that anyone consuming the url can't + // determine if the sort/filter originated from QuerySettings or from a user applied sort/filter. + getSettings().getBaseFilter().applyToURL(ret, DATAREGIONNAME_DEFAULT); + + if (!getSettings().getBaseSort().getSortList().isEmpty()) + getSettings().getBaseSort().applyToURL(ret, DATAREGIONNAME_DEFAULT, true); + + switch (action) + { + case deleteQuery: + case sourceQuery: + break; + case detailsQueryRow: + case updateQueryRow: + case insertQueryRow: + case importData: + case updateQueryRows: + case deleteQueryRows: + ret.addReturnUrl(getReturnUrl()); + break; + case editSnapshot: + ret.addParameter("snapshotName", getSettings().getQueryName()); + case createSnapshot: + + case exportRowsExcel: + case exportRowsXLSX: + case exportRowsTsv: + case exportScript: + case signRowsExcel: + case signRowsXLSX: + case signRowsTsv: + case selectAll: + case printRows: + { + if (_useQueryViewActionExportURLs) + { + ret = getViewContext().cloneActionURL(); + ret.addParameter("exportType", action.name()); + ret.addParameter("dataRegionName", getExportRegionName()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(getExportRegionName() + ".maxRows"); + ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); + break; + } + ActionURL expandedURL = getViewContext().cloneActionURL(); + addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); + // Copy the other parameters that aren't scoped to the data region as well. Some exports may use them. + // For example, see issue 15451 + for (Map.Entry entry : expandedURL.getParameterMap().entrySet()) + { + String name = entry.getKey(); + // schemaName isn't prefixed with the data region name, and don't specify a special data region name + if (!name.equals("schemaName") && !name.equals("dataRegionName") && !name.startsWith(getDataRegionName() + ".") && !name.startsWith(DATAREGIONNAME_DEFAULT + ".")) + { + for (String value : entry.getValue()) + { + ret.addParameter(entry.getKey(), value); + } + } + } + + ret.addParameter(DATAREGIONNAME_DEFAULT + "." + QueryParam.selectionKey, getSelectionKey()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); + ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); + break; + } + case excelWebQueryDefinition: + { + if (_useQueryViewActionExportURLs) + { + ActionURL expandedURL = getViewContext().cloneActionURL(); + expandedURL.addParameter("exportType", EXCEL_WEB_QUERY_EXPORT_TYPE); + expandedURL.addParameter("exportRegion", getDataRegionName()); + ret.addParameter("queryViewActionURL", expandedURL.getLocalURIString()); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(getExportRegionName() + ".maxRows"); + ret.replaceParameter(getExportRegionName() + ".showRows", ShowRows.ALL.toString()); + break; + } + ActionURL expandedURL = getViewContext().cloneActionURL(); + addParamsByPrefix(ret, expandedURL, getDataRegionName() + ".", DATAREGIONNAME_DEFAULT + "."); + + // NOTE: Default export will export all rows, but the user may choose to export ShowRows.SELECTED in the export panel + ret.deleteParameter(DATAREGIONNAME_DEFAULT + ".maxRows"); + ret.replaceParameter(DATAREGIONNAME_DEFAULT + ".showRows", ShowRows.ALL.toString()); + break; + } + case createRReport: + ScriptReportBean bean = new ScriptReportBean(); + bean.setReportType(RReport.TYPE); + bean.setSchemaName(_schema.getSchemaName()); + bean.setQueryName(getSettings().getQueryName()); + bean.setViewName(getSettings().getViewName()); + bean.setDataRegionName(getDataRegionName()); + + bean.setRedirectUrl(getReturnUrl().getLocalURIString()); + return ReportUtil.getScriptReportDesignerURL(_viewContext, bean); + } + return ret; + } + + protected ActionButton actionButton(String label, QueryAction action) + { + return actionButton(label, action, null, null); + } + + protected ActionButton actionButton(String label, QueryAction action, @Nullable String parameterToAdd, @Nullable String parameterValue) + { + ActionURL url = urlFor(action); + if (url == null) + { + return null; + } + if (parameterToAdd != null) + url.addParameter(parameterToAdd, parameterValue); + return new ActionButton(label, url); + } + + protected String param(QueryParam param) + { + return param(param.toString()); + } + + protected String param(String param) + { + return getDataRegionName() + "." + param; + } + + protected URLHelper urlRefreshQuery() + { + URLHelper ret = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + ret = ret.clone(); + ret.deleteParameter(param(QueryParam.queryName)); + ret.deleteParameter(param(QueryParam.viewName)); + ret.deleteParameter(param(QueryParam.reportId)); + for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) + { + ret.deleteFilterParameters(key); + } + return ret; + } + + protected ActionURL urlBaseView() + { + ActionURL ret = getSettings().getSortFilterURL(); + for (String key : ret.getKeysByPrefix(getDataRegionName() + ".")) + { + ret.deleteFilterParameters(key); + } + ret.deleteParameter(DataRegion.LAST_FILTER_PARAM); + return ret; + } + + protected URLHelper urlChangeView() + { + URLHelper ret = getSettings().getReturnUrlHelper(); + if (null == ret) + { + ret = getSettings().getSortFilterURL(); + } + else if (getSettings().getDataRegionName() != null) + { + ret = ret.clone(); + // if we are using a returnUrl for this QV, make sure we apply any sort and filter + // parameters so that reports stay in sync with the data region. + URLHelper url = getSettings().getSortFilterURL(); + for (String param : url.getKeysByPrefix(getSettings().getDataRegionName())) + { + ret.replaceParameter(param, url.getParameter(param)); + } + } + else + { + ret = ret.clone(); + } + + ret.deleteParameter(param(QueryParam.viewName)); + ret.deleteParameter(param(QueryParam.reportId)); + ret.deleteParameter(RunReportView.CACHE_PARAM); + ret.deleteParameter(RunReportView.TAB_PARAM); + return ret; + } + + protected void addParamsByPrefix(ActionURL target, ActionURL source, String oldPrefix, String newPrefix) + { + for (String key : source.getKeysByPrefix(oldPrefix)) + { + String suffix = key.substring(oldPrefix.length()); + String newKey = newPrefix + suffix; + for (String value : source.getParameterValues(key)) + { + boolean isQueryParam = false; + try + { + Enum.valueOf(QueryParam.class, suffix); + isQueryParam = true; + } + catch (Exception ignore) { } + + if (suffix.equals("sort")) + { + // Prepend source sort parameter before target's existing sort + String targetSort = target.getParameter(key); + if (targetSort != null && !targetSort.isEmpty()) + value = value + "," + targetSort; + target.replaceParameter(newKey, value); + } + else if (isQueryParam) + { + // Issue 20779: Error: Query 'Containers,Containers' in schema 'core' doesn't exist + // Issue 21101: Cannot export QueryWebPart views using a custom sql query to Excel file + // Only a single non-empty value is accepted for query parameters -- overwrite the existing parameter so we don't have duplicate parameters. + if (value != null && !value.isEmpty()) + target.replaceParameter(newKey, value); + } + else + { + target.addParameter(newKey, value); + } + } + } + } + + protected boolean canInsert() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), InsertPermission.class) && table.getUpdateService() != null; + } + + protected boolean canUpdate() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), UpdatePermission.class) && table.getUpdateService() != null; + } + + protected boolean canDelete() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), DeletePermission.class); + } + + protected boolean isAdmin() + { + TableInfo table = getTable(); + return table != null && table.hasPermission(getUser(), AdminPermission.class); + } + + private boolean allowQueryTableInsertURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasInsertURLOverride() && table.allowQueryTableURLOverrides(); + } + + protected boolean allowQueryTableUpdateURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasUpdateURLOverride() && table.allowQueryTableURLOverrides(); + } + + private boolean allowQueryTableDeleteURLOverride() + { + TableInfo table = getTable(); + return table != null && table.hasDeleteURLOverride() && table.allowQueryTableURLOverrides(); + } + + public boolean showInsertNewButton() + { + return _showInsertNewButton; + } + + public void setShowInsertNewButton(boolean showInsertNewButton) + { + _showInsertNewButton = showInsertNewButton; + } + + public boolean showImportDataButton() + { + return _showImportDataButton; + } + + public void setShowImportDataButton(boolean show) + { + _showImportDataButton = show; + } + + public boolean showDeleteButton() + { + return _showDeleteButton; + } + + public void setShowDeleteButton(boolean showDeleteButton) + { + _showDeleteButton = showDeleteButton; + } + + public boolean showDeleteButtonConfirmationText() + { + return _showDeleteButtonConfirmationText; + } + + public void setShowDeleteButtonConfirmationText(boolean showDeleteButtonConfirmationText) + { + _showDeleteButtonConfirmationText = showDeleteButtonConfirmationText; + } + + public boolean showRecordSelectors() + { + return _showRecordSelectors; + } + + /** + * Show record selectors usually doesn't need to be explicitly set. If the ButtonBar contains + * a button that requires selection, the record selectors will be added. + */ + public void setShowRecordSelectors(boolean showRecordSelectors) + { + _showRecordSelectors = showRecordSelectors; + } + + protected void populateReportButtonBar(ButtonBar bar) + { + MenuButton queryButton = createQueryPickerButton("Query"); + queryButton.setVisible(getSettings().getAllowChooseQuery()); + bar.add(queryButton); + + if (getSettings().getAllowChooseView()) + { + bar.add(createViewButton(_itemFilter)); + populateChartsReports(bar); + } + + if (showExportButtons()) + { + ActionButton b = createPrintButton(); + if (null != b) + bar.add(b); + } + } + + protected void populateButtonBar(DataView view, ButtonBar bar) + { + MenuButton queryButton = createQueryPickerButton("Query"); + queryButton.setVisible(getSettings().getAllowChooseQuery()); + bar.add(queryButton); + + if (getSettings().getAllowChooseView()) + { + bar.add(createViewButton(_itemFilter)); + } + + populateChartsReports(bar); + + if ((canInsert() || allowQueryTableInsertURLOverride()) && (showInsertNewButton() || showImportDataButton())) + { + bar.add(createInsertMenuButton()); + } + + if ((canDelete() || allowQueryTableDeleteURLOverride()) && showDeleteButton()) + { + bar.add(createDeleteButton()); + } + + if (showExportButtons()) + { + List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); + + PanelButton b = createExportButton(recordSelectorColumns); + if (b.hasSubPanels()) + { + // Issue 24530: Add record selectors for exporting selected items. Assumes that all export panels support selection. + if ((recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || (getTable() != null && !getTable().getPkColumns().isEmpty())) + { + bar.setAlwaysShowRecordSelectors(true); + } + bar.add(b); + } + + ActionButton rs = createExportToRStudioButton(); + if (null != rs) + bar.add(rs); + } + } + + @Nullable ActionButton createExportToRStudioButton() + { + ActionButton rstudio = new ActionButton("RStudio"); + String script = DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleButtonPanel('export','rstudio'); return false;"; + rstudio.setScript(script, false); + rstudio.setVisible(showRStudioButton()); + rstudio.setEnabled(_hasExportRStudioPanel); + rstudio.setDisplayPermission(ReadPermission.class); + return rstudio; + } + + @Nullable + public ActionButton createEditMultipleButton() + { + ActionButton btn = null; + ActionURL editMultipleURL = urlFor(QueryAction.updateQueryRows); + if (editMultipleURL != null) + { + editMultipleURL.addParameter(DataRegionSelection.DATA_REGION_SELECTION_KEY, _settings.getSelectionKey()); + btn = new ActionButton(editMultipleURL, "Edit Multiple"); + btn.setActionType(ActionButton.Action.POST); + btn.setDisplayPermission(UpdatePermission.class); + btn.setRequiresSelection(true, 2, null); + } + return btn; + } + + @Nullable + public ActionButton createDeleteButton() + { + return createDeleteButton(showDeleteButtonConfirmationText()); + } + + public ActionButton createDeleteButton(boolean showConfirmation) + { + ActionURL urlDelete = urlFor(QueryAction.deleteQueryRows); + if (urlDelete != null) + { + ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); + btnDelete.setIconCls("trash"); + btnDelete.setActionType(ActionButton.Action.POST); + btnDelete.setDisplayPermission(DeletePermission.class); + if (showConfirmation) + btnDelete.setRequiresSelection(true, "Are you sure you want to delete the selected row?", "Are you sure you want to delete the selected rows?"); + else + btnDelete.setRequiresSelection(true); + return btnDelete; + } + return null; + } + + public ActionButton createInsertMenuButton() + { + return createInsertMenuButton(null, null); + } + + public ActionButton createInsertMenuButton(ActionURL overrideInsertUrl, ActionURL overrideImportUrl) + { + MenuButton button = new MenuButton("Insert"); + button.setTooltip(getInsertButtonText(INSERT_DATA_TEXT)); + button.setIconCls("plus"); + boolean hasInsertNewOption = false; + boolean hasImportDataOption = false; + + if (showInsertNewButton()) + { + ActionURL urlInsert = overrideInsertUrl == null ? urlFor(QueryAction.insertQueryRow) : overrideInsertUrl; + if (urlInsert != null) + { + NavTree insertNew = new NavTree(getInsertButtonText(getInsertButtonText(INSERT_ROW_TEXT)), urlInsert); + button.addMenuItem(insertNew); + hasInsertNewOption = true; + } + } + + if (showImportDataButton()) + { + ActionURL urlImport = overrideImportUrl == null ? urlFor(QueryAction.importData) : overrideImportUrl; + if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) + { + NavTree importData = new NavTree(getInsertButtonText(IMPORT_BULK_DATA_TEXT), urlImport); + button.addMenuItem(importData); + hasImportDataOption = true; + } + } + + return hasInsertNewOption && hasImportDataOption? button : hasInsertNewOption ? createInsertButton() : hasImportDataOption ? createImportButton() : null; + } + + public ActionButton createInsertButton() + { + ActionURL urlInsert = urlFor(QueryAction.insertQueryRow); + if (urlInsert != null) + { + ActionButton btnInsert = new ActionButton(urlInsert, getInsertButtonText(INSERT_ROW_TEXT)); + btnInsert.setActionType(ActionButton.Action.LINK); + btnInsert.setTooltip(getInsertButtonText(INSERT_ROW_TEXT)); + btnInsert.setIconCls("plus"); + return btnInsert; + } + return null; + } + + public ActionButton createImportButton() + { + ActionURL urlImport = urlFor(QueryAction.importData); + if (urlImport != null && urlImport != AbstractTableInfo.LINK_DISABLER_ACTION_URL) + { + ActionButton btnInsert = new ActionButton(urlImport, getInsertButtonText(IMPORT_BULK_DATA_TEXT)); + btnInsert.setActionType(ActionButton.Action.LINK); + btnInsert.setTooltip(getInsertButtonText(IMPORT_BULK_DATA_TEXT)); + btnInsert.setIconCls("plus"); + return btnInsert; + } + return null; + } + + protected String getInsertButtonText(String btnTxt) + { + return StringUtils.capitalize(btnTxt.toLowerCase()); + } + + @Nullable + protected ActionButton createPrintButton() + { + ActionButton btnPrint = actionButton("Print", QueryAction.printRows); + if (null == btnPrint) + return null; + btnPrint.setIconCls("print"); + btnPrint.setTarget("_blank"); + return btnPrint; + } + + private ActionButton createShareButton(@NotNull ActionURL url, @Nullable String tooltip) + { + ActionButton shareBtn = new ActionButton(url, "Share"); + shareBtn.setActionType(ActionButton.Action.LINK); + shareBtn.setIconCls("share"); + if (tooltip != null) + shareBtn.setTooltip(tooltip); + + return shareBtn; + } + + /** + * Make all links rendered in columns target the specified browser window/tab + */ + public void setLinkTarget(String linkTarget) + { + _linkTarget = linkTarget; + } + + public abstract static class ExportOptionsBean + { + private final String _dataRegionName; + private final String _exportRegionName; + private final String _selectionKey; + private final ColumnHeaderType _headerType; + private final boolean _includeSignButton; + private final String _email; + + protected ExportOptionsBean(String dataRegionName, String exportRegionName, @Nullable String selectionKey, + ColumnHeaderType headerType, boolean includeSignButton, @Nullable String email) + { + _dataRegionName = dataRegionName; + _exportRegionName = exportRegionName; + _selectionKey = selectionKey; + _headerType = headerType; + _includeSignButton = includeSignButton; + _email = email; + } + + public String getDataRegionName() + { + return _dataRegionName; + } + + public String getExportRegionName() + { + return _exportRegionName; + } + + @Nullable + public String getSelectionKey() + { + return _selectionKey; + } + + /** @return false if the region won't support row selectors, usually because it doesn't have a primary key */ + public boolean isSelectable() + { + return _selectionKey != null; + } + + public boolean hasSelected(ViewContext context) + { + if (!isSelectable()) + { + return false; + } + Set selected = DataRegionSelection.getSelected(context, _selectionKey, false); + return !selected.isEmpty(); + } + + public ColumnHeaderType getHeaderType() + { + return _headerType; + } + + public boolean isIncludeSignButton() + { + return _includeSignButton; + } + + public String getEmail() + { + return _email; + } + } + + public static class ExcelExportOptionsBean extends ExportOptionsBean + { + private final ActionURL _xlsURL; + private final ActionURL _xlsxURL; + private final ActionURL _iqyURL; + private final ActionURL _signXlsURL; + private final ActionURL _signXlsxURL; + + public ExcelExportOptionsBean( + String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, + ActionURL xlsURL, ActionURL xlsxURL, ActionURL iqyURL, ActionURL signXlsURL, ActionURL signXlsxURL, @Nullable String email) + { + super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signXlsURL && null != signXlsxURL), email); + _xlsURL = xlsURL; + _xlsxURL = xlsxURL; + _iqyURL = iqyURL; + _signXlsURL = null != signXlsURL ? signXlsURL : new ActionURL(); + _signXlsxURL = null != signXlsxURL ? signXlsxURL : new ActionURL(); + } + + @NotNull + public ActionURL getXlsxURL() + { + return _xlsxURL; + } + + public ActionURL getIqyURL() + { + return _iqyURL; + } + + @NotNull + public ActionURL getXlsURL() + { + return _xlsURL; + } + + @NotNull + public ActionURL getSignXlsURL() + { + return _signXlsURL; + } + + @NotNull + public ActionURL getSignXlsxURL() + { + return _signXlsxURL; + } + } + + public static class TextExportOptionsBean extends ExportOptionsBean + { + private final ActionURL _tsvURL; + private final ActionURL _signTsvURL; + + public TextExportOptionsBean( + String dataRegionName, String exportRegionName, @Nullable String selectionKey, ColumnHeaderType headerType, + ActionURL tsvURL, ActionURL signTsvURL, @Nullable String email) + { + super(dataRegionName, exportRegionName, selectionKey, headerType, (null != signTsvURL), email); + _tsvURL = tsvURL; + _signTsvURL = null != signTsvURL ? signTsvURL : new ActionURL(); + } + + @NotNull + public ActionURL getTsvURL() + { + return _tsvURL; + } + + @NotNull + public ActionURL getSignTsvURL() + { + return _signTsvURL; + } + } + + @NotNull + public PanelButton createExportButton(@Nullable List recordSelectorColumns) + { + String buttonText = "Export"; + ActionURL signRowsXlsURL = null; + ActionURL signRowsXlsxURL = null; + ActionURL signRowsTsvURL = null; + ComplianceService complianceService = ComplianceService.get(); + if (complianceService.hasElecSignPermission(getContainer(), getUser()) && !getUser().isImpersonated()) + { + // We build a URL using Query's mechanism because it does a lot of work to get the properties right; + // Then build our URL to the ComplianceController using those properties. If any fail, just bail on creating button. + signRowsXlsURL = complianceService.urlFor(getContainer(), QueryAction.signRowsExcel, urlFor(QueryAction.signRowsExcel)); + signRowsXlsxURL = complianceService.urlFor(getContainer(), QueryAction.signRowsXLSX, urlFor(QueryAction.signRowsXLSX)); + signRowsTsvURL = complianceService.urlFor(getContainer(), QueryAction.signRowsTsv, urlFor(QueryAction.signRowsTsv)); + if (null != signRowsXlsURL && null != signRowsXlsxURL && null != signRowsTsvURL) + buttonText += " / Sign Data"; + } + + PanelButton button = new PanelButton("export", buttonText, getDataRegionName()); + button.setActionName("export"); // #32594: API can set a buttonConfig including "export"; since the caption may differ, add action so BuiltinButtonConfig can figure it out + ActionURL xlsURL = urlFor(QueryAction.exportRowsExcel); + ActionURL xlsxURL = urlFor(QueryAction.exportRowsXLSX); + ActionURL tsvURL = urlFor(QueryAction.exportRowsTsv); + + button.setIconCls("download"); + button.setTabAlignTop(true); + boolean hasRecordSelectors = (recordSelectorColumns != null && !recordSelectorColumns.isEmpty()) || + (getTable() != null && !getTable().getPkColumns().isEmpty()); + + if (xlsURL != null && xlsxURL != null) + { + ExcelExportOptionsBean excelBean = new ExcelExportOptionsBean( + getDataRegionName(), + getExportRegionName(), + hasRecordSelectors ? getSettings().getSelectionKey() : null, + getColumnHeaderType(), + xlsURL, + xlsxURL, + _allowExportExternalQuery ? urlFor(QueryAction.excelWebQueryDefinition) : null, + signRowsXlsURL, + signRowsXlsxURL, + getUser().getEmail() + ); + button.addSubPanel("Excel", new JspView<>("/org/labkey/api/query/excelExportOptions.jsp", excelBean)); + } + + if (tsvURL != null) + { + TextExportOptionsBean textBean = new TextExportOptionsBean( + getDataRegionName(), + getExportRegionName(), + hasRecordSelectors ? getSettings().getSelectionKey() : null, + getColumnHeaderType(), + tsvURL, + signRowsTsvURL, + getUser().getEmail() + ); + button.addSubPanel("Text", new JspView<>("/org/labkey/api/query/textExportOptions.jsp", textBean)); + } + + if (_allowExportExternalQuery) + { + addExportScriptItems(button); + addExportRStudio(button, hasRecordSelectors ? getSettings().getSelectionKey() : null); + } + + return button; + } + + + public void addExportRStudio(PanelButton exportButton, String selectionKey) + { + RStudioService rss = RStudioService.get(); + if (null == rss || null == rss.getRStudioLink(getUser(), getContainer())) + return; + if (null == getExportScriptFactory("r")) + return; + ActionURL exportUrl = urlFor(QueryAction.exportScript); + if (null == exportUrl) + return; + exportUrl.replaceParameter("scriptType","r"); + TextExportOptionsBean textBean = new TextExportOptionsBean(getDataRegionName(), getExportRegionName(), selectionKey, + getColumnHeaderType(), exportUrl, null, null); + HttpView exportView = rss.getExportToRStudioView(textBean); + if (exportView == null) + return; + exportButton.addSubPanel("RStudio", exportView); + _hasExportRStudioPanel = true; + } + + + public void addExportScriptItems(PanelButton button) + { + if (!_exportScriptFactories.isEmpty()) + { + Map options = new LinkedHashMap<>(); + + for (ExportScriptFactory factory : _exportScriptFactories.values()) + { + ActionURL url = urlFor(QueryAction.exportScript); + if (null != url) + { + url.addParameter("scriptType", factory.getScriptType()); + options.put(factory.getMenuText(), url); + } + } + + if (!options.isEmpty()) + button.addSubPanel("Script", new JspView<>("/org/labkey/api/query/scriptExportOptions.jsp", options)); + } + } + + public ReportService.ItemFilter getViewItemFilter() + { + return _itemFilter; + } + + public void setViewItemFilter(ReportService.ItemFilter filter) + { + if (filter != null) + _itemFilter = filter; + } + + public MenuButton createViewButton(ReportService.ItemFilter filter) + { + setViewItemFilter(filter); + String current = null; + + // if we are not rendering a report or not showing reports, we use the current view name to set the menu item + // selection, an empty string denotes the default view, a customized default view will have a null name. + if (_report == null || !_showReports) + current = (_customView != null) ? Objects.toString(_customView.getName(), "") : ""; + + URLHelper target = urlChangeView(); + MenuButton button = new MenuButton("Grid Views"); + button.setTooltip("Grid views"); + button.setIconCls("table"); + NavTree menu = button.getNavTree(); + + if (getSettings().isAllowCustomizeView()) + addCustomizeViewItems(button); + + if (!getQueryDef().isTemporary()) + { + button.addSeparator(); + addGridViews(button, target, current); + button.addSeparator(); + addManageViewItems(button, PageFlowUtil.map( + "schemaName", getSchema().getSchemaName(), + "queryName", getSettings().getQueryName())); + addFilterItems(button); + } + + return button; + } + + protected MenuButton createReportButton() + { + MenuButton button = new MenuButton("Reports"); + NavTree menu = button.getNavTree(); + + if (!getQueryDef().isTemporary() && _report == null) + { + List reportDesigners = new ArrayList<>(); + getSettings().setSchemaName(getSchema().getSchemaName()); + + for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) + { + for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) + { + if (designerInfo.getType() != ReportService.DesignerType.VISUALIZATION) + reportDesigners.add(designerInfo); + } + } + + reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); + + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (ReportService.DesignerInfo designer : reportDesigners) + { + if (viewItemFilter.accept(designer.getReportType(), designer.getLabel())) + { + NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); + item.setImageSrc(designer.getIconURL()); + item.setImageCls(designer.getIconCls()); + + menu.addChild(item); + } + } + } + + // existing reports + if (!getQueryDef().isTemporary()) + { + addReportViews(button); + } + + return button; + } + + private MenuButton createChartButton() + { + MenuButton button = new MenuButton("Charts"); + button.setIconCls("area-chart"); + + if (!getQueryDef().isTemporary() && _report == null) + { + List reportDesigners = new ArrayList<>(); + getSettings().setSchemaName(getSchema().getSchemaName()); + + for (ReportService.UIProvider provider : ReportService.get().getUIProviders()) + { + for (ReportService.DesignerInfo designerInfo : provider.getDesignerInfo(getViewContext(), getSettings())) + { + if (designerInfo.getType() == ReportService.DesignerType.VISUALIZATION) + reportDesigners.add(designerInfo); + } + } + + reportDesigners.sort(Comparator.comparing(ReportService.DesignerInfo::getLabel)); + + for (ReportService.DesignerInfo designer : reportDesigners) + { + NavTree item = new NavTree("Create " + designer.getLabel(), designer.getDesignerURL()); + item.setImageSrc(designer.getIconURL()); + item.setImageCls(designer.getIconCls()); + button.addMenuItem(item); + } + } + + if (!getQueryDef().isTemporary()) + { + addChartViews(button); + } + + return button; + } + + protected void populateChartsReports(ButtonBar bar) + { + if (isShowReports()) + { + MenuButton reportButton = createReportButton(); + MenuButton chartButton = createChartButton(); + NavTree uiProviderLinks = createUIProviderLinks(); + + if (reportButton.getNavTree().hasChildren()) + { + chartButton.setTooltip("Charts / Reports"); + NavTree chartMenu = chartButton.getNavTree(); + chartMenu.addSeparator(); + for (NavTree child : reportButton.getNavTree().getChildren()) + chartButton.addMenuItem(child); + } + if (uiProviderLinks != null && uiProviderLinks.hasChildren()) + { + chartButton.addSeparator(); + for (NavTree child : uiProviderLinks.getChildren()) + chartButton.addMenuItem(child); + } + + if (chartButton.getNavTree().hasChildren()) + bar.add(chartButton); + } + } + + private NavTree createUIProviderLinks() + { + NavTree menu = null; + List uiProviders = ReportService.get().getUIProviders(); + Map> uiProviderAddedViews = new TreeMap<>(); + + for (ReportService.UIProvider provider : uiProviders) + { + for (Pair additionalItem : provider.getAdditionalChartingMenuItems(getViewContext(), getSettings())) + { + if (!uiProviderAddedViews.containsKey(additionalItem.second)) + uiProviderAddedViews.put(additionalItem.second, new ArrayList<>()); + uiProviderAddedViews.get(additionalItem.second).add(additionalItem.first); + } + } + + if (!uiProviderAddedViews.isEmpty()) + { + menu = new NavTree(); + for (Map.Entry> entry : uiProviderAddedViews.entrySet()) + { + List navItems = entry.getValue(); + navItems.sort(Comparator.comparing(NavTree::getText)); + for (NavTree item : navItems) + menu.addChild(item); + } + } + + return menu; + } + + public ReportService.ItemFilter getItemFilter() + { + QueryDefinition def = QueryService.get().getQueryDef(getUser(), getContainer(), getSchema().getSchemaName(), getSettings().getQueryName()); + if (def == null) + def = QueryService.get().createQueryDefForTable(getSchema(), getSettings().getQueryName()); + + return new WrappedItemFilter(_itemFilter, def); + } + + private static class WrappedItemFilter implements ReportService.ItemFilter + { + private final ReportService.ItemFilter _filter; + private final Map _filterItemMap = new HashMap<>(); + + + public WrappedItemFilter(ReportService.ItemFilter filter, QueryDefinition def) + { + _filter = filter; + + if (def != null) + { + for (ViewOptions.ViewFilterItem item : def.getViewOptions().getViewFilterItems()) + _filterItemMap.put(item.getViewType(), item); + } + } + + @Override + public boolean accept(String type, String label) + { + if (_filter.accept(type, label)) + { + if (_filterItemMap.containsKey(type)) + return _filterItemMap.get(type).isEnabled(); + else + return true; + } + + if (_filterItemMap.containsKey(type)) + return _filterItemMap.get(type).isEnabled(); + + return false; + } + } + + protected void addFilterItems(MenuButton button) + { + if (_customView != null && _customView.hasFilterOrSort()) + { + URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + url = url.clone(); + NavTree item; + String label = "Apply Grid Filter"; + if (ignoreUserFilter()) + { + url.deleteParameter(param(QueryParam.ignoreFilter)); + item = new NavTree(label, url); + } + else + { + url.replaceParameter(param(QueryParam.ignoreFilter), "1"); + item = new NavTree(label, url); + item.setSelected(true); + } + item.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".clearSelected({quiet: true});"); + button.addMenuItem(item); + } + + TableInfo t = getTable(); + if (t instanceof UnionTable ut) + { + t = ut.getComponentTable(); // check against a component table + } + if (null != t && t.supportsContainerFilter() && !getAllowableContainerFilterTypes().isEmpty()) + { + NavTree containerFilterItem = new NavTree("Folder Filter"); + button.addMenuItem(containerFilterItem); + + ContainerFilter selectedFilter = getContainerFilter(); + ContainerFilter.Type selectedFilterType = null != selectedFilter ? selectedFilter.getType() : ContainerFilter.Type.Current; + + for (ContainerFilter.Type filterType : getAllowableContainerFilterTypes()) + { + URLHelper url = getSettings().getReturnUrlHelper(getSettings().getSortFilterURL()); + url = url.clone(); + String propName = getDataRegionName() + DataRegion.CONTAINER_FILTER_NAME; + url.replaceParameter(propName, filterType.name()); + NavTree filterItem = new NavTree(filterType.toString(), url); + + if (selectedFilterType == filterType) + { + filterItem.setSelected(true); + } + filterItem.setNoFollow(true); + containerFilterItem.addChild(filterItem); + } + } + } + + protected String getChangeViewScript(String viewName) + { + return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'view', viewName:" + PageFlowUtil.jsString(viewName) + "});"; + } + + protected String getChangeReportScript(String reportId) + { + return DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".changeView({type:'report', reportId:" + PageFlowUtil.jsString(reportId) + "});"; + } + + protected void addGridViews(MenuButton menu, URLHelper target, String currentView) + { + List views = new ArrayList<>(getQueryDef().getCustomViews(getViewContext().getUser(), getViewContext().getRequest(), false, false).values()); + List viewItems = new ArrayList<>(); + + // default grid view stays at the top level. The default will have a getName == null + boolean hasDefault = false; + for (CustomView view : views) + { + if (view.getName() == null) + { + hasDefault = true; + break; + } + } + + // To make generating menu items easier, create a default custom view if it doesn't exist yet. + if (!hasDefault) + { + // don't pass getUser() as owner, we want the default view to appear as "public" + CustomView defaultView = getQueryDef().createCustomView(); + views.add(0, defaultView); + } + + // sort the grid view alphabetically, with default first (null name), then private views over public ones + views.sort((o1, o2) -> + { + if (o1.getName() == null) return -1; + if (o2.getName() == null) return 1; + if (!o1.isShared() && o2.isShared()) return -1; + if (o1.isShared() && !o2.isShared()) return 1; + + return o1.getName().compareToIgnoreCase(o2.getName()); + }); + + for (CustomView view : views) + { + if (view.isHidden()) + continue; + + NavTree item; + String name = view.getName(); + if (name == null) + { + String label = Objects.toString(view.getLabel(), "Default"); + + item = new NavTree(label, (ActionURL) null); + item.setScript(getChangeViewScript("")); + if ("".equals(currentView)) + item.setStrong(true); + } + else + { + String label = view.getLabel(); + + item = new NavTree(label, (ActionURL) null); + item.setScript(getChangeViewScript(name)); + if (name.equals(currentView)) + item.setStrong(true); + } + + StringBuilder description = new StringBuilder(); + if (view.isSession()) + { + item.setEmphasis(true); + description.append("Unsaved "); + } + if (view.isShared()) + description.append("Shared "); + else + description.append("Private "); + + if (view.getContainer() != null && !view.getContainer().equals(getContainer())) + description.append("Inherited from '").append(PageFlowUtil.filter(view.getContainer().getPath())).append("'"); + + if (!description.isEmpty()) + item.setDescription(description.toString()); + + try + { + URLHelper iconUrl; + if (null != view.getCustomIconUrl()) + iconUrl = new URLHelper(view.getCustomIconUrl()); + else + iconUrl = new URLHelper(view.isShared() ? "/reports/grid.gif" : "/reports/icon_private_view.png"); + iconUrl.setContextPath(AppProps.getInstance().getParsedContextPath()); + item.setImageSrc(iconUrl); + + if (null != view.getCustomIconCls()) + item.setImageCls(view.getCustomIconCls()); + } + catch (URISyntaxException e) + { + _log.error("Invalid custom view icon url", e); + } + + viewItems.add(item); + menu.addMenuItem(item); + } + + // enable menu filtering for the module list if > 10 items + if (viewItems.size() > 10) + { + String menuFilterItemCls = PopupMenuView.getMenuFilterItemCls(menu.getNavTree()); + for (NavTree item : viewItems) + item.setMenuFilterItemCls(menuFilterItemCls); + } + + } + + protected void addReportViews(MenuButton menu) + { + List allReports = new ArrayList<>(); + // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too + for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) + { + allReports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); + } + Map> views = new TreeMap<>(); + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (Report report : allReports) + { + // Filter out reports that don't match what this view is supposed to show. This can prevent + // reports that were created on the same schema and table/query from a different view from showing up on a + // view that's doing magic to add additional filters, for example. + if (viewItemFilter.accept(report.getType(), null) + && !report.getType().equals(TimeChartReport.TYPE) + && !report.getType().equals(GenericChartReport.TYPE)) + { + if (canViewReport(getUser(), getContainer(), report) && !report.getDescriptor().isHidden()) + { + if (!views.containsKey(report.getType())) + views.put(report.getType(), new ArrayList<>()); + + views.get(report.getType()).add(report); + } + } + } + + if (!views.isEmpty()) + menu.addSeparator(); + + for (Map.Entry> entry : views.entrySet()) + { + List reports = entry.getValue(); + + // sort the list of reports within each type grouping + reports.sort((o1, o2) -> + { + String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); + String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); + + return n1.compareToIgnoreCase(n2); + }); + + for (Report report : reports) + { + String reportId = report.getDescriptor().getReportId().toString(); + NavTree item = new NavTree(report.getDescriptor().getReportName(), (ActionURL) null); + if (report.getDescriptor().getReportId().equals(getSettings().getReportId())) + item.setStrong(true); + item.setImageSrc(ReportUtil.getIconUrl(getContainer(), report)); + item.setScript(getChangeReportScript(reportId)); + menu.addMenuItem(item); + } + } + } + + protected void addChartViews(MenuButton menu) + { + List reports = new ArrayList<>(); + // Ask the schema for the report keys so that we get legacy ones for backwards compatibility too + for (String reportKey : getSchema().getReportKeys(getSettings().getQueryName())) + { + reports.addAll(ReportUtil.getReportsIncludingInherited(getContainer(), getUser(), reportKey)); + } + Map> views = new TreeMap<>(); + ReportService.ItemFilter viewItemFilter = getItemFilter(); + + for (Report report : reports) + { + // Filter out reports that don't match what this view is supposed to show. This can prevent + // reports that were created on the same schema and table/query from a different view from showing up on a + // view that's doing magic to add additional filters, for example. + if (viewItemFilter.accept(report.getType(), null) && + (report.getType().equals(TimeChartReport.TYPE) || report.getType().equals(GenericChartReport.TYPE))) + { + if (canViewReport(getUser(), getContainer(), report)) + { + if (!views.containsKey(report.getType())) + views.put(report.getType(), new ArrayList<>()); + + views.get(report.getType()).add(report); + } + } + } + + if (!views.isEmpty()) + menu.addSeparator(); + + for (Map.Entry> entry : views.entrySet()) + { + List charts = entry.getValue(); + + charts.sort((o1, o2) -> + { + String n1 = Objects.toString(o1.getDescriptor().getReportName(), ""); + String n2 = Objects.toString(o2.getDescriptor().getReportName(), ""); + + return n1.compareToIgnoreCase(n2); + }); + + for (Report chart : charts) + { + String chartId = chart.getDescriptor().getReportId().toString(); + NavTree item = new NavTree(chart.getDescriptor().getReportName(), (ActionURL) null); + item.setImageSrc(ReportUtil.getIconUrl(getContainer(), chart)); + item.setImageCls(ReportUtil.getIconCls(chart)); + item.setScript(getChangeReportScript(chartId)); + + if (chart.getDescriptor().getReportId().equals(getSettings().getReportId())) + item.setStrong(true); + + menu.addMenuItem(item); + } + } + } + + protected boolean canViewReport(User user, Container c, Report report) + { + return true; + } + + public void addCustomizeViewItems(MenuButton button) + { + if (_report == null) + { + ActionURL urlTableInfo = getSchema().urlFor(QueryAction.tableInfo); + urlTableInfo.addParameter(QueryParam.queryName.toString(), getQueryDef().getName()); + + NavTree customizeView = new NavTree("Customize Grid"); + customizeView.setScript(DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".toggleShowCustomizeView();"); + customizeView.setImageCls("fa fa-pencil"); + button.addMenuItem(customizeView); + } + + if (isAdmin() && QueryService.get().isQuerySnapshot(getContainer(), getSchema().getSchemaName(), getSettings().getQueryName())) + { + QuerySnapshotService.Provider provider = QuerySnapshotService.get(getSchema().getSchemaName()); + if (provider != null) + { + NavTree item = button.addMenuItem("Edit Snapshot", provider.getEditSnapshotURL(getSettings(), getViewContext())); + } + } + } + + public void addManageViewItems(MenuButton button, Map params) + { + ActionURL url = PageFlowUtil.urlProvider(ReportUrls.class).urlManageViews(getContainer()); + for (Map.Entry entry : params.entrySet()) + url.addParameter(entry.getKey(), entry.getValue()); + + NavTree item = button.addMenuItem("Manage Views", url); + item.setImageCls("fa fa-cog"); + } + + public String getDataRegionName() + { + return getSettings().getDataRegionName(); + } + + private String getExportRegionName() + { + return _useQueryViewActionExportURLs ? getDataRegionName() : DATAREGIONNAME_DEFAULT; + } + + private String _baseId = null; + + /** + * Use this html encoded dataRegionName as the base id for menus and attribute values that need to be rendered into the DOM. + */ + protected String getBaseMenuId() + { + if (_baseId == null) + _baseId = PageFlowUtil.filter(getDataRegionName()); + return _baseId; + } + + protected String h(Object o) + { + return PageFlowUtil.filter(o); + } + + /** + * this is the choke point for rendering reports and views, if this method is overridden you need to call + * super in order to have report/view rendering to work properly. + */ + @Override + protected void renderView(Object model, HttpServletRequest request, HttpServletResponse response) throws Exception + { + if (isReportView(getViewContext())) + renderReportView(request, response); + else + renderDataRegion(HtmlWriter.of(response)); + } + + private void renderReportView(HttpServletRequest request, HttpServletResponse response) throws IOException + { + if (_report != null) + { + try + { + ReportDataRegion dr = new ReportDataRegion(getSettings(), getViewContext(), _report); + RenderContext ctx = new RenderContext(getViewContext()); + + if (!isPrintView()) + { + // not sure why this is necessary (adding the reportId to the context) + ctx.put("reportId", _report.getDescriptor().getReportId()); + + ButtonBar bar = new ButtonBar(); + populateReportButtonBar(bar); + + if (_report.allowShareButton(getUser(), getContainer())) + { + ActionURL shareUrl = PageFlowUtil.urlProvider(ReportUrls.class).urlShareReport(getContainer(), _report); + if (shareUrl != null) + bar.add(createShareButton(shareUrl, "Share report")); + } + + dr.setButtonBar(bar); + } + dr.render(ctx, request, response); + + // if the user is viewing a shared report, remove any notifications related to it + NotificationService.get().removeNotifications( + getContainer(), _report.getDescriptor().getReportId().toString(), + Collections.singletonList(Report.SHARE_REPORT_TYPE), getUser().getUserId() + ); + } + catch (Exception e) + { + renderErrors(HtmlWriter.of(response), "Error rendering report : " + _report.getDescriptor().getReportName(), Collections.singletonList(e)); + } + } + } + + protected SqlDialect getSqlDialect() + { + return getSchema().getDbSchema().getSqlDialect(); + } + + protected DataRegion createDataRegion() + { + DataRegion rgn = new DataRegion(); + configureDataRegion(rgn); + return rgn; + } + + protected void configureDataRegion(DataRegion rgn) + { + rgn.setDisplayColumns(getDisplayColumns()); + rgn.setSettings(getSettings()); + rgn.setShowRecordSelectors(showRecordSelectors()); + rgn.setSelectAllURL(urlFor(QueryAction.selectAll)); + + rgn.setShadeAlternatingRows(isShadeAlternatingRows()); + rgn.setShowFilterDescription(isShowFilterDescription()); + rgn.setShowBorders(isShowBorders()); + rgn.setShowSurroundingBorder(isShowSurroundingBorder()); + rgn.setShowPagination(isShowPagination()); + rgn.setShowPaginationCount(isShowPaginationCount()); + + if (_messageSupplier != null) + rgn.addMessageSupplier(_messageSupplier); + + if (_customView != null && _customView.getErrors() != null) + { + rgn.addMessageSupplier(dataRegion -> _customView.getErrors().stream() + .map(e -> new DataRegion.Message(e, DataRegion.MessageType.ERROR, DataRegion.MessagePart.view)) + .collect(Collectors.toList())); + } + + TableInfo table = getTable(); + if (table instanceof FilteredTable ft && ft.hasRulesOmittedColumns()) + { + rgn.addMessageSupplier(x -> List.of(new DataRegion.Message("PHI protected columns have been omitted", DataRegion.MessageType.WARNING, DataRegion.MessagePart.header))); + } + + // Allow region to specify header lock, optionally override + if (rgn.getAllowHeaderLock()) + rgn.setAllowHeaderLock(getSettings().getAllowHeaderLock()); + + rgn.setTable(table); + + if (isShowConfiguredButtons()) + { + // We first apply the button bar config from the table: + ButtonBarConfig tableBarConfig = table == null ? null : table.getButtonBarConfig(); + if (tableBarConfig != null) + rgn.addButtonBarConfig(tableBarConfig); + // Then any overriding button bar config (from javascript) is applied: + if (_buttonBarConfig != null) + rgn.addButtonBarConfig(_buttonBarConfig); + } + + if (table != null && table.getAggregateRowConfig() != null) + { + rgn.setAggregateRowConfig(table.getAggregateRowConfig()); + } + } + + public void setButtonBarPosition(DataRegion.ButtonBarPosition buttonBarPosition) + { + _buttonBarPosition = buttonBarPosition; + } + + public void setButtonBarConfig(ButtonBarConfig buttonBarConfig) + { + _buttonBarConfig = buttonBarConfig; + } + + public ButtonBarConfig getButtonBarConfig() + { + return _buttonBarConfig; + } + + private boolean isReportView(ViewContext viewContext) + { + _report = getSettings().getReportView(viewContext); + + return _report != null && StringUtils.trimToNull(getSettings().getViewName()) == null; + } + + public DataView createDataView() + { + DataRegion rgn = createDataRegion(); + + //if explicit set of fieldkeys has been set + //add those specifically to the region + if (null != getSettings().getFieldKeys()) + { + TableInfo table = getTable(); + if (table != null) + { + rgn.clearColumns(); + List keys = getSettings().getFieldKeys(); + FieldKey starKey = FieldKey.fromParts("*"); + + // include details and update columns if they've been requested + addDetailsAndUpdateColumns(rgn.getDisplayColumns(), table); + + //special-case: if one of the keys is *, add all columns from the + //TableInfo and remove the * so that Query doesn't choke on it + if (keys.contains(starKey)) + { + rgn.addColumns(table.getColumns()); + keys.remove(starKey); + // Since the client requested all columns, don't filter which ones get sent back + getSettings().setFieldKeys(null); + } + + if (!keys.isEmpty()) + { + Map selectedCols = QueryService.get().getColumns(table, keys); + for (ColumnInfo col : selectedCols.values()) + rgn.addColumn(col); + } + } + } + else if (null != getSettings().getExtraFieldKeys()) + { + TableInfo table = getTable(); + if (table != null) + { + List keys = getSettings().getExtraFieldKeys(); + if (!keys.isEmpty()) + { + Map selectedCols = QueryService.get().getColumns(table, keys); + for (ColumnInfo col : selectedCols.values()) + rgn.addColumn(col); + } + } + } + + GridView ret = new GridView(rgn, _errors); + setupDataView(ret); + return ret; + } + + protected void setupDataView(DataView ret) + { + DataRegion rgn = ret.getDataRegion(); + ret.setFrame(WebPartView.FrameType.NONE); + rgn.setAllowAsync(true); + ButtonBar bb = new ButtonBar(); + if (!(isApiResponseView() || isPrintView() || isExportView())) + { + populateButtonBar(ret, bb); + + // TODO: Until the "More" menu is dynamically populated the "Print" button has been moved back to the bar. + // Print button is rendered separately to respect ordering -- we want it rendering after all custom buttons + // added by overrides of populateButtonBar(). + // bar.add(populateMoreMenu()); + if (showExportButtons()) + bb.add(createPrintButton()); + } + rgn.setButtonBar(bb); + + rgn.setButtonBarPosition(isApiResponseView() || isPrintView() ? DataRegion.ButtonBarPosition.NONE : _buttonBarPosition); + + if (getSettings() != null && getSettings().getShowRows() == ShowRows.ALL) + { + // Don't cache if the ResultSet is likely to be very large + ret.getRenderContext().setCache(false); + } + + ActionURL customViewUrl = null; + if (_customView != null && _customView.hasFilterOrSort() && !ignoreViewFilter()) + { + customViewUrl = new ActionURL(); + _customView.applyFilterAndSortToURL(customViewUrl, getDataRegionName()); + } + + // Apply base sorts and filters from custom view and from QuerySettings. + if (!ignoreUserFilter()) + { + SimpleFilter filter; + if (ret.getRenderContext().getBaseFilter() instanceof SimpleFilter) + { + filter = (SimpleFilter) ret.getRenderContext().getBaseFilter(); + } + else + { + filter = new SimpleFilter(ret.getRenderContext().getBaseFilter()); + } + Sort sort = ret.getRenderContext().getBaseSort(); + if (sort == null) + { + sort = new Sort(); + } + + // We need to set the base sort/filter _before_ adding the customView sort/filter. + // If the user has set a sort on their custom view, we want their sort to take precedence. + filter.addAllClauses(getSettings().getBaseFilter()); + sort.insertSort(getSettings().getBaseSort()); + + if (customViewUrl != null) + { + try + { + filter.addUrlFilters(customViewUrl, getDataRegionName()); + } + catch (ConversionException e) + { + _errors.reject(ERROR_MSG, "Invalid grid view filter: " + e.getMessage()); + } + sort.addURLSort(customViewUrl, getDataRegionName()); + } + + ret.getRenderContext().setBaseFilter(filter); + ret.getRenderContext().setBaseSort(sort); + } + + // Apply analytics providers from custom view and query settings + List analyticsProviders = new LinkedList<>(); + if (ret.getRenderContext().getBaseAnalyticsProviders() != null) + analyticsProviders.addAll(ret.getRenderContext().getBaseAnalyticsProviders()); + if (getSettings().getAnalyticsProviders() != null) + analyticsProviders.addAll(getSettings().getAnalyticsProviders()); + if (customViewUrl != null) + analyticsProviders.addAll(AnalyticsProviderItem.fromURL(customViewUrl, getDataRegionName())); + ret.getRenderContext().setBaseAnalyticsProviders(analyticsProviders); + + // XXX: Move to QuerySettings? + if (_customView != null) + ret.getRenderContext().setView(_customView); + + // TODO: Don't set available container filters in render context + // 11082: Need to push list of available container filters to DataRegion.js + ret.getRenderContext().put("allowableContainerFilterTypes", getAllowableContainerFilterTypes()); + } + + + protected void renderDataRegion(HtmlWriter out) throws Exception + { + // make sure table has been instantiated + getTable(); + List errors = getParseErrors(); + if (errors.isEmpty()) + { + include(createDataView(), out.unwrap()); + } + else + { + renderErrors(out, "Query '" + getQueryDef().getName() + "' has errors", errors); + } + } + + + protected ColumnHeaderType getColumnHeaderType() + { + return ColumnHeaderType.Caption; + } + + public TSVGridWriter getTsvWriter() throws IOException + { + return getTsvWriter(getColumnHeaderType()); + } + + protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType) throws IOException + { + return getTsvWriter(headerType, Collections.emptyMap()); + } + + protected TSVGridWriter getTsvWriter(ColumnHeaderType headerType, @NotNull Map renameColumnMap) + { + _exportView = true; + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setAllowAsync(false); + rgn.setShowPagination(false); + rgn.prepareDisplayColumns(getContainer()); + RenderContext rc = view.getRenderContext(); + rc.setCache(false); + TSVGridWriter tsv = new TSVGridWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), renameColumnMap); + tsv.setFilenamePrefix(getSettings().getQueryName() != null ? getSettings().getQueryName() : "query"); + // don't step on default + if (null != headerType) + tsv.setColumnHeaderType(headerType); + return tsv; + } + + public Results getResults() throws SQLException, IOException + { + return getResults(ShowRows.ALL); + } + + public Results getResults(ShowRows showRows) throws SQLException, IOException + { + return getResults(showRows, false, false); + } + + public Results getResults(ShowRows showRows, boolean async, boolean cache) throws SQLException, IOException + { + _exportView = true; + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + ShowRows prevShowRows = getSettings().getShowRows(); + try + { + // Set to the desired row policy + getSettings().setShowRows(showRows); + rgn.setAllowAsync(async); + view.getRenderContext().setCache(cache); + RenderContext ctx = view.getRenderContext(); + if (null == rgn.getResults(ctx)) + return null; + return new ResultsImpl(ctx); + } + finally + { + // We have to reset the show-rows setting, since we don't know what's going to be done with this + // queryview after the call to 'getResults'. It's possible it could still be rendered to the client, + // as happens with study datasets. + getSettings().setShowRows(prevShowRows); + } + } + + + @Nullable + public ResultSet getResultSet() throws SQLException, IOException + { + Results r = getResults(); + return r == null ? null : r.getResultSet(); + } + + + public List getExportColumns(List list) + { + List ret = new ArrayList<>(list); + ret.removeIf(next -> next instanceof DetailsColumn || next instanceof UpdateColumn); + return ret; + } + + public final ExcelWriter getExcelWriter(@NotNull ExcelExportConfig config) throws IOException + { + // Call the appropriate overridden method + ExcelWriter ew = getExcelWriter(config.getDocType(), null); + return configureExcelWriter(ew, config); + } + + public ExcelWriter getExcelWriter(ExcelWriter.ExcelDocumentType docType, @Nullable Map renameColumnMap) throws IOException + { + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + RenderContext rc = configureForExcelExport(docType, view, rgn); + + ExcelWriter ew = new ExcelWriter(()->rgn.getResults(rc), getExportColumns(rgn.getDisplayColumns()), docType, renameColumnMap); + + ew.setFilenamePrefix(getSettings().getQueryName()); + ew.setAutoSize(true); + return ew; + } + + /** + * Sets configuration settings for the provided ExcelWriter according the provided config and this QueryView + * @param excelWriter to configure (CALLER TO CLOSE) + * @param config additional properties to set on the writer + */ + public ExcelWriter configureExcelWriter(ExcelWriter excelWriter, ExcelExportConfig config) + { + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + RenderContext rc = configureForExcelExport(excelWriter.getDocumentType(), view, rgn); + rgn.prepareDisplayColumns(view.getViewContext().getContainer()); + rgn.setAllowAsync(false); + excelWriter.setDisplayColumns(getExportColumns(rgn.getDisplayColumns())); + excelWriter.setResultsFactory(()->rgn.getResults(rc)); + excelWriter.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); + excelWriter.setRenameColumnMap(config.getRenamedColumns()); + excelWriter.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); + excelWriter.setAutoSize(true); + + return excelWriter; + } + + protected ExcelWriter getExcelTemplateWriter(@NotNull ExcelExportConfig config) + { + // The template should be based on the actual columns in the table, not the user's default view, + // which may be hiding columns or showing values joined through lookups + + //NOTE: if the the user passed a viewName param on the URL, we will use these columns + //with the caveat that we will skip and non-user editable columns or those that do + //map to fields in this table (ie. lookups). we will also append any missing + //required columns. + + //TODO: the latter might be problematic if the value of required column is set + //in a validation script. however, the dev could always set it to userEditable=false or nullable=true + List fieldKeys = new ArrayList<>(20); + TableInfo t = createTable(); + + if (!config.getRespectView()) + { + for (ColumnInfo columnInfo : t.getColumns()) + { + FieldKey fieldKey = columnInfo.getFieldKey(); + // Issue 43760: "isUserEditable" does not mean what you think it means. UniqueIdFields must be marked as "UserEditable" + // in order to show up in a details view, but then that makes them show up in the export, where they shouldn't. Booo. + if (config.getIncludeColumns().contains(fieldKey) || (columnInfo.isUserEditable() && !columnInfo.isUniqueIdField())) + { + fieldKeys.add(fieldKey); + } + } + + // Add remaining includeCols to the end + for (FieldKey includeCol : config.getIncludeColumns()) + { + if (!fieldKeys.contains(includeCol)) + fieldKeys.add(includeCol); + } + + } + else + { + // get list of required columns so we can verify presence + Set requiredCols = new HashSet<>(config.getIncludeColumns()); + for (ColumnInfo c : t.getColumns()) + { + if (c.inferIsShownInInsertView()) + requiredCols.add(c.getFieldKey()); + } + + + for (FieldKey key : getCustomView().getColumns()) + { + if (key.getParent() != null) + continue; + + if (requiredCols.contains(key)) + { + fieldKeys.add(key); + requiredCols.remove(key); + continue; + } + + Map cols = QueryService.get().getColumns(t, Collections.singleton(key)); + ColumnInfo col = cols.get(key); + if (col != null && col.isUserEditable()) + { + fieldKeys.add(key); + requiredCols.remove(key); + } + } + + // Add any remaining required columns to the end + fieldKeys.addAll(requiredCols); + } + + List displayColumns = getExcelTemplateDisplayColumns(fieldKeys); + return new ExcelWriter(()->null, displayColumns, config.getDocType(), config.getRenamedColumns()); + } + + protected List getExcelTemplateDisplayColumns(List fieldKeys) + { + // Force the view to use our special list + getSettings().setFieldKeys(fieldKeys); + + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setAllowAsync(false); + rgn.setShowPagination(false); + + // Add explicitly requested columns, even if they don't actually exist on the table. + // They may be magic columns supported on the import side, e.g. "MaterialsInputs/Foo" for SampleTypes. + List displayColumns = rgn.getDisplayColumns(); + Set displayColumnFieldKeys = displayColumns.stream() + .map(DisplayColumn::getColumnInfo) + .filter(Objects::nonNull) + .map(ColumnInfo::getFieldKey) + .collect(Collectors.toSet()); + + for (FieldKey fieldKey : fieldKeys) + { + if (!displayColumnFieldKeys.contains(fieldKey)) + { + DisplayColumn dc = new SimpleDisplayColumn(); + dc.setName(fieldKey.getName()); + displayColumns.add(dc); + } + } + + displayColumns = getExportColumns(displayColumns); + + // Need to remove special MV columns + displayColumns.removeIf(col -> col.getColumnInfo() instanceof RawValueColumn); + + return displayColumns; + } + + protected RenderContext configureForExcelExport(ExcelWriter.ExcelDocumentType docType, DataView view, DataRegion rgn) + { + if (getSettings().getShowRows() == ShowRows.ALL) + { + // Limit the rows returned based on the document type. + // The maxRows setting isn't used unless showRows is PAGINATED. + getSettings().setShowRows(ShowRows.PAGINATED); + getSettings().setMaxRows(docType.getMaxRows()); + } + getSettings().setOffset(Table.NO_OFFSET); + rgn.prepareDisplayColumns(view.getViewContext().getContainer()); // Prep the display columns to translate generic date/time formats, see #21094 + rgn.setAllowAsync(false); + RenderContext rc = view.getRenderContext(); + // Cache resultset only for SAS/SHARE data sources. See #12966 (which removed caching) and #13638 (which added it back for SAS) + boolean sas = "SAS".equals(rgn.getTable().getSqlDialect().getProductName()); + rc.setCache(sas); + return rc; + } + + public static class ExcelExportConfig + { + private HttpServletResponse response; + private ColumnHeaderType headerType; + private Workbook workbook = null; + private ExcelWriter.ExcelDocumentType docType = ExcelWriter.ExcelDocumentType.xlsx; + private Map renamedColumns = new HashMap<>(); + private boolean templateOnly = false; + private boolean insertColumnsOnly = false; + private boolean respectView = false; + private List includeColumns = Collections.emptyList(); + private List excludeColumns = Collections.emptyList(); + private String prefix = null; + + public ExcelExportConfig(HttpServletResponse response, ColumnHeaderType headerType) + { + this.response = response; + this.headerType = headerType; + } + + public ExcelExportConfig setPrefix(String prefix) + { + this.prefix = prefix; + return this; + } + + public String getPrefix() + { + return this.prefix; + } + + public ExcelExportConfig setExcludeColumns(List excludeColumns) + { + this.excludeColumns = excludeColumns; + return this; + } + + public List getExcludeColumns() + { + return this.excludeColumns; + } + + public ExcelExportConfig setIncludeColumns(List includeColumns) + { + this.includeColumns = includeColumns; + return this; + } + + public List getIncludeColumns() + { + return this.includeColumns; + } + + public ExcelExportConfig setRespectView(boolean respectView) + { + this.respectView = respectView; + return this; + } + + public boolean getRespectView() + { + return this.respectView; + } + + public ExcelExportConfig setInsertColumnsOnly(boolean insertColumnsOnly) + { + this.insertColumnsOnly = insertColumnsOnly; + return this; + } + + public boolean getInsertColumnsOnly() + { + return this.insertColumnsOnly; + } + + public ExcelExportConfig setHeaderType(ColumnHeaderType headerType) + { + this.headerType = headerType; + return this; + } + + public ColumnHeaderType getHeaderType() + { + return this.headerType; + } + + public ExcelExportConfig setTemplateOnly(boolean templateOnly) + { + this.templateOnly = templateOnly; + return this; + } + + public boolean getTemplateOnly() + { + return this.templateOnly; + } + + public ExcelExportConfig setRenamedColumns(Map renamedColumns) + { + this.renamedColumns = renamedColumns; + return this; + } + + public Map getRenamedColumns() + { + return this.renamedColumns; + } + + public ExcelExportConfig setDocType(ExcelWriter.ExcelDocumentType docType) + { + this.docType = docType; + return this; + } + + public ExcelWriter.ExcelDocumentType getDocType() + { + return this.docType; + } + + public ExcelExportConfig setResponse(HttpServletResponse response) + { + this.response = response; + return this; + } + + public @NotNull HttpServletResponse getResponse() + { + return this.response; + } + public ExcelExportConfig setWorkbook(Workbook workbook) + { + this.workbook = workbook; + return this; + } + + public @Nullable Workbook getWorkbook() + { + return this.workbook; + } + } + + public void exportToExcel(HttpServletResponse response) throws IOException + { + exportToExcel(new ExcelExportConfig(response, getColumnHeaderType())); + } + + public void exportToExcel(HttpServletResponse response, Workbook workbook) throws IOException + { + exportToExcel(new ExcelExportConfig(response, getColumnHeaderType()).setWorkbook(workbook)); + } + + public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType) throws IOException + { + exportToExcel(new ExcelExportConfig(response, headerType).setDocType(docType)); + } + + public void exportToExcel(HttpServletResponse response, ColumnHeaderType headerType, ExcelWriter.ExcelDocumentType docType, @NotNull Map renameColumn) throws IOException + { + exportToExcel( + new ExcelExportConfig(response, headerType) + .setDocType(docType) + .setRenamedColumns(renameColumn) + ); + } + + public void exportToExcel(ExcelExportConfig config) throws IOException + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + ExcelWriter ew = config.getTemplateOnly() ? getExcelTemplateWriter(config) : getExcelWriter(config); + ew.setCaptionType(config.getHeaderType() == null ? getColumnHeaderType() : config.getHeaderType()); + ew.setShowInsertableColumnsOnly(config.getInsertColumnsOnly(), config.getIncludeColumns(), config.getExcludeColumns()); + if (config.getPrefix() != null) + ew.setFilenamePrefix(config.getPrefix()); + ew.setAutoSize(true); + ew.renderWorkbook(config.getResponse()); + + if (!config.getTemplateOnly()) + logAuditEvent("Exported to Excel", ew.getDataRowCount()); + } + } + + @Nullable + public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, @Nullable Map metadata, + @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + return exportToExcelFile(docType, getColumnHeaderType(), metadata, rowsOut, includeTimestamp); + } + + @Nullable + public ByteArrayAttachmentFile exportToExcelFile(ExcelWriter.ExcelDocumentType docType, ColumnHeaderType headerType, @Nullable Map metadata, + @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + try (OutputStream stream = new BufferedOutputStream(byteStream)) + { + ExcelWriter ew = getExcelWriter(docType, null); + ew.setCaptionType(headerType); + ew.setShowInsertableColumnsOnly(false, null); + ew.setMetadata(metadata); + ew.renderWorkbook(stream); + String extension = docType.name(); + String filename = includeTimestamp ? + FileUtil.makeFileNameWithTimestamp(ew.getFilenamePrefix(), extension) : + ew.getFilenamePrefix() + "." + extension; + ByteArrayAttachmentFile byteArrayAttachmentFile = + new ByteArrayAttachmentFile(filename, byteStream.toByteArray(), docType.getMimeType()); + + if (null != rowsOut) + rowsOut.add(ew.getDataRowCount()); + logAuditEvent("Exported to Excel file", ew.getDataRowCount()); + return byteArrayAttachmentFile; + } + } + + return null; + } + + public void exportToTsv(HttpServletResponse response) throws IOException + { + exportToTsv(response, TSVWriter.DELIM.TAB, TSVWriter.QUOTE.DOUBLE, getColumnHeaderType()); + } + + public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType) throws IOException + { + exportToTsv(response, delim, quote, headerType, Collections.emptyMap()); + } + + public void exportToTsv(final HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException + { + _exportView = true; + TableInfo table = getTable(); + + if (table != null) + { + int rowCount = doExport(response, delim, quote, headerType, renameColumnMap); + logAuditEvent("Exported to TSV", rowCount); + } + } + + private int doExport(HttpServletResponse response, final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, @NotNull Map renameColumnMap) throws IOException + { + try (TSVGridWriter tsv = renameColumnMap.isEmpty() ? getTsvWriter(headerType) : getTsvWriter(headerType, renameColumnMap)) + { + tsv.setDelimiterCharacter(delim); + tsv.setQuoteCharacter(quote); + tsv.write(response); + return tsv.getDataRowCount(); + } + } + + @Nullable + public ByteArrayAttachmentFile exportToTsvFile(final TSVWriter.DELIM delim, final TSVWriter.QUOTE quote, ColumnHeaderType headerType, + @Nullable List commentLines, @Nullable List rowsOut, boolean includeTimestamp) throws Exception + { + _exportView = true; + TableInfo table = getTable(); + if (table != null) + { + StringBuilder tsvBuilder = new StringBuilder(); + + try (TSVGridWriter tsvWriter = getTsvWriter(headerType)) + { + tsvWriter.setDelimiterCharacter(delim); + tsvWriter.setQuoteCharacter(quote); + if (null != commentLines) + tsvWriter.setFileHeader(commentLines); + tsvWriter.write(tsvBuilder); + String extension = delim.extension; + String filename = includeTimestamp ? + FileUtil.makeFileNameWithTimestamp(tsvWriter.getFilenamePrefix(), extension) : + tsvWriter.getFilenamePrefix() + "." + extension; + String contentType = delim.contentType; + ByteArrayAttachmentFile byteArrayAttachmentFile = new ByteArrayAttachmentFile(filename, tsvBuilder.toString().getBytes(StringUtilsLabKey.DEFAULT_CHARSET), contentType); + + if (null != rowsOut) + rowsOut.add(tsvWriter.getDataRowCount()); + logAuditEvent("Exported to TSV file", tsvWriter.getDataRowCount()); + return byteArrayAttachmentFile; + } + } + + return null; + } + + public void exportToApiResponse(ApiQueryResponse response) + { + TableInfo table = getTable(); + if (table != null) + { + _apiResponseView = true; + setShowDetailsColumn(response.isIncludeDetailsColumn()); + setShowUpdateColumn(response.isIncludeUpdateColumn()); + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + rgn.setShowPaginationCount(!response.isMetaDataOnly()); + + //force the pk column(s) into the default list of columns + List pkCols = table.getPkColumns(); + for (ColumnInfo pkCol : pkCols) + { + if (null == rgn.getDisplayColumn(pkCol.getName())) + rgn.addColumn(pkCol); + } + + RenderContext ctx = view.getRenderContext(); + rgn.setAllowAsync(false); + rgn.prepareDisplayColumns(ctx.getContainer()); + List displayColumns; + if (response.isIncludeDetailsColumn() || response.isIncludeUpdateColumn()) + displayColumns = rgn.getDisplayColumns(); + else + displayColumns = getExportColumns(rgn.getDisplayColumns()); + response.initialize(ctx, rgn, table, displayColumns); + } + else + { + //table was null--try to get parse errors + List errors = getParseErrors(); + if (null != errors && !errors.isEmpty()) + throw errors.get(0); + } + } + + public void exportToExcelWebQuery(HttpServletResponse response) throws Exception + { + TableInfo table = getTable(); + if (null == table) + return; + + DataView view = createDataView(); + DataRegion rgn = view.getDataRegion(); + + // Backwards compatibility for export URLs that don't specify a showRows value, see issue 24523 + if (getViewContext().getRequest().getParameter(getSettings().getDataRegionName() + ".showRows") == null) + { + getSettings().setShowRows(ShowRows.ALL); + } + + // We're not sure if we're dealing with a version of Excel that can handle more than 65535 rows. + // Assume that it can, and rely on the fact that Excel throws out rows if there are more than it can handle + RenderContext ctx = configureForExcelExport(ExcelWriter.ExcelDocumentType.xlsx, view, rgn); + + Results results = rgn.getResults(ctx); + + // Bug 5610 & 6179. Excel web queries don't work over SSL if caching is disabled, + // so we need to allow caching so that Excel can read from IE on Windows. + // Set the headers to allow the client to cache, but not proxies + ResponseHelper.setPrivate(response); + + HtmlExportWriter writer = new HtmlExportWriter(); + writer.write(results, getExportColumns(rgn.getDisplayColumns()), response, ctx, true); + + logAuditEvent("Exported to Excel Web Query data", writer.getDataRowCount()); + } + + /** + * Mark all rows in the query view as selected in the user's session. + */ + public int selectAll() throws IOException + { + if (StringUtils.isEmpty(getSelectionKey())) + throw new IllegalStateException(); + + TableInfo table = getTable(); + if (table == null) + throw new IllegalStateException(); + + return DataRegionSelection.setSelectionForAll(this, this.getSelectionKey(), true); + } + + public void logAuditEvent(String comment, int dataRowCount) + { + QueryService.get().addAuditEvent(this, comment, dataRowCount); + } + + public CustomView getCustomView() + { + return _customView; + } + + public void setCustomView(CustomView customView) + { + _customView = customView; + } + + public void setCustomView(String viewName) + { + _settings.setViewName(viewName); + _customView = _settings.getCustomView(getViewContext(), getQueryDef()); + } + + protected TableInfo createTable() + { + QueryDefinition qdef = getQueryDef(); + if (null == qdef) + return null; + qdef.setContainerFilter(getContainerFilter()); + return qdef.getTable(_schema, _parseErrors, true); + } + + final public TableInfo getTable() + { + // We'll have parseErrors if we already tried and failed to create the table + if (_table != null || !_parseErrors.isEmpty()) + return _table; + _table = createTable(); + + /* TODO ContainerFilter check that this is correct for hasUnionTable() */ + if (_table instanceof ContainerFilterable && _table.supportsContainerFilter()) + { + ContainerFilter filter = getContainerFilter(); + if (filter != null) + { + // If table has a Union version, apply the filter to the Union + UserSchema userSchema = _table.getUserSchema(); + if (ContainerFilter.Type.Current != filter.getType() && null != userSchema && _table.hasUnionTable()) + { + Set containers = new HashSet<>(); + if (ContainerFilter.Type.AllFolders != filter.getType()) + { + Collection containerIds = filter.getIds(); + if (null != containerIds) + { + for (GUID id : containerIds) + containers.add(ContainerManager.getForId(id)); + } + } + else + { + containers = ContainerManager.getAllChildren(ContainerManager.getRoot()); + } + + if (!containers.isEmpty()) + _table = userSchema.getUnionTable(_table, containers); + } + } + } + + return _table; + } + + // This can be used to override the container filter that would otherwise be provided by the QuerySettings + ContainerFilter _overrideContainerFilter = null; + + public void setContainerFilter(ContainerFilter cf) + { + _overrideContainerFilter = cf; + } + + @Nullable + protected ContainerFilter getContainerFilter() + { + if (null != _overrideContainerFilter) + return _overrideContainerFilter; + + String filterName = _settings.getContainerFilterName(); + + if (filterName == null && _customView != null) + filterName = _customView.getContainerFilterName(); + + if (filterName != null) + return ContainerFilter.getContainerFilterByName(filterName, getContainer(), getUser()); + + return null; + } + + private boolean isShowExperimentalGenericDetailsURL() + { + return AppProps.getInstance().isOptionalFeatureEnabled(EXPERIMENTAL_GENERIC_DETAILS_URL); + } + + + List _queryDefDisplayColumns = null; + + public List getDisplayColumns() + { + TableInfo table = getTable(); + if (table == null) + return Collections.emptyList(); + + List ret = new ArrayList<>(); + addDetailsAndUpdateColumns(ret, table); + + if (null == _queryDefDisplayColumns) + _queryDefDisplayColumns = getQueryDef().getDisplayColumns(_customView, table); + ret.addAll(_queryDefDisplayColumns); + + if (_linkTarget != null) + { + for (DisplayColumn displayColumn : ret) + { + displayColumn.setLinkTarget(_linkTarget); + } + } + return ret; + } + + protected void addDetailsAndUpdateColumns(List ret, TableInfo table) + { + // Print view and export view don't need details and update columns, + // but the selectRows API can turn them on to include the URLs in the response format. + if (isPrintView() || isExportView()) + return; + + if (_showDetailsColumn && (null != _detailsURL || table.hasDetailsURL() || isShowExperimentalGenericDetailsURL())) + { + StringExpression urlDetails = urlExpr(QueryAction.detailsQueryRow); + + if (urlDetails != null && urlDetails != AbstractTableInfo.LINK_DISABLER) + { + // We'll decide at render time if we have enough columns in the results to make the DetailsColumn visible + DisplayColumn dc = createDetailsColumn(urlDetails, table); + if (null != dc) + ret.add(dc); + } + } + + if (_showUpdateColumn && (canUpdate() || allowQueryTableUpdateURLOverride())) + { + StringExpression urlUpdate = urlExpr(QueryAction.updateQueryRow); + if (urlUpdate != null) + { + DisplayColumn dc = createUpdateColumn(urlUpdate, table); + if (null != dc) + ret.add(0, dc); + } + } + } + + /** + * The intent of this method is to ensure that the update/details URL inherit the + * ContainerContext from the table unless explicitly set. This is relevant because QWPs can + * supply custom update/detailsURLs as a string, which has no ContainerContext. Most TableInfos + * always set the ContainerContext on the details/update URLs to ContainerContext.FieldKeyContext, + * which delegates the container to row-level (usually based on a container column). + */ + private void ensureUrlContainerContext(StringExpression se, TableInfo table) + { + if (se instanceof DetailsURL du) + { + if (!du.hasContainerContext()) + { + du.setContainerContext(table.getContainerContext()); + } + } + } + + @Nullable + protected DisplayColumn createDetailsColumn(StringExpression urlDetails, TableInfo table) + { + ensureUrlContainerContext(urlDetails, table); + + return new DetailsColumn(urlDetails, table); + } + + protected DisplayColumn createUpdateColumn(StringExpression urlUpdate, TableInfo table) + { + ensureUrlContainerContext(urlUpdate, table); + + return new UpdateColumn.Impl(urlUpdate); + } + + public QueryDefinition getQueryDef() + { + return _queryDef; + } + + public List getParseErrors() + { + return _parseErrors; + } + + public NavTrailConfig getNavTrailConfig() + { + NavTrailConfig ret = new NavTrailConfig(getRootContext()); + ret.setExtraChildren(new NavTree(getSchema().getSchemaName() + " queries", getSchema().urlFor(QueryAction.begin))); + return ret; + } + + public void setShowExportButtons(boolean showExportButtons) + { + _showExportButtons = showExportButtons; + } + + public boolean showExportButtons() + { + return _showExportButtons; + } + + public boolean showRStudioButton() + { + return _showRStudioButton; + } + + /** Currently requires showExportButtons(), or button will not be enabled */ + public void setShowRStudioButton(boolean showRStudioButton) + { + _showRStudioButton = showRStudioButton; + } + + public void setShowDetailsColumn(boolean showDetailsColumn) + { + _showDetailsColumn = showDetailsColumn; + } + + public void setShowUpdateColumn(boolean showUpdateColumn) + { + _showUpdateColumn = showUpdateColumn; + } + + public void setUpdateURL(String updateURL) + { + _updateURL = null==updateURL ? null : DetailsURL.fromString(updateURL); + } + + public void setUpdateURL(DetailsURL updateURL) + { + _updateURL = updateURL; + } + + public void setDetailsURL(String detailsURL) + { + _detailsURL = null==detailsURL ? null : DetailsURL.fromString(detailsURL); + } + + public void setDetailsURL(DetailsURL detailsURL) + { + _detailsURL = detailsURL; + } + + public void setDeleteURL(String deleteURL) + { + _deleteURL = deleteURL; + } + + public void setInsertURL(String insertURL) + { + _insertURL = insertURL; + } + + public void setImportURL(String importURL) + { + _importURL = importURL; + } + + public void setPrintView(boolean b) + { + _printView = b; + } + + public boolean isPrintView() + { + return _printView; + } + + public boolean isExportView() + { + return _exportView; + } + + public boolean isApiResponseView() + { + return _apiResponseView; + } + + public void setApiResponseView(boolean apiResponseView) + { + _apiResponseView = apiResponseView; + } + + public boolean isUseQueryViewActionExportURLs() + { + return _useQueryViewActionExportURLs; + } + + public void setUseQueryViewActionExportURLs(boolean useQueryViewActionExportURLs) + { + _useQueryViewActionExportURLs = useQueryViewActionExportURLs; + } + + public boolean isAllowExportExternalQuery() + { + return _allowExportExternalQuery; + } + + public void setAllowExportExternalQuery(boolean allowExportExternalQuery) + { + _allowExportExternalQuery = allowExportExternalQuery; + } + + public boolean isShadeAlternatingRows() + { + return _shadeAlternatingRows; + } + + public void setShadeAlternatingRows(boolean shadeAlternatingRows) + { + _shadeAlternatingRows = shadeAlternatingRows; + } + + public boolean isShowFilterDescription() + { + return _showFilterDescription; + } + + public void setShowFilterDescription(boolean showFilterDescription) + { + _showFilterDescription = showFilterDescription; + } + + public boolean isShowBorders() + { + return _showBorders; + } + + public void setShowBorders(boolean showBorders) + { + _showBorders = showBorders; + } + + public boolean isShowSurroundingBorder() + { + return _showSurroundingBorder; + } + + public void setShowSurroundingBorder(boolean showSurroundingBorder) + { + _showSurroundingBorder = showSurroundingBorder; + } + + public boolean isShowPagination() + { + return _showPagination; + } + + public void setShowPagination(boolean showPagination) + { + _showPagination = showPagination; + } + + public boolean isShowPaginationCount() + { + return _showPaginationCount; + } + + public void setShowPaginationCount(boolean showPaginationCount) + { + _showPaginationCount = showPaginationCount; + } + + /** + * controls display of the reports and charts button + */ + public boolean isShowReports() + { + // buttons can be hidden either through query settings or method overriding + return _showReports && getSettings().isShowReports(); + } + + public void setShowReports(boolean showReports) + { + _showReports = showReports; + } + + public boolean isShowConfiguredButtons() + { + return _showConfiguredButtons; + } + + public void setShowConfiguredButtons(boolean showConfiguredButtons) + { + _showConfiguredButtons = showConfiguredButtons; + } + + @NotNull + public Set getAllowableContainerFilterTypes() + { + return _allowableContainerFilterTypes; + } + + public void setAllowableContainerFilterTypes(@NotNull Collection allowableContainerFilterTypes) + { + _allowableContainerFilterTypes = Collections.unmodifiableSet(new LinkedHashSet<>(allowableContainerFilterTypes)); + } + + public void setAllowableContainerFilterTypes(ContainerFilter.Type... allowableContainerFilterTypes) + { + setAllowableContainerFilterTypes(Arrays.asList(allowableContainerFilterTypes)); + } + + public void disableContainerFilterSelection() + { + _allowableContainerFilterTypes = Collections.emptySet(); + } + + public List getAnalyticsProviders() + { + return getSettings().getAnalyticsProviders(); + } + + @NotNull + @Override + public LinkedHashSet getClientDependencies() + { + LinkedHashSet resources = new LinkedHashSet<>(); + resources.addAll(super.getClientDependencies()); + + ButtonBarConfig cfg = _buttonBarConfig; + if (cfg == null) + { + TableInfo ti = _table; + if (ti == null) + { + List errors = new ArrayList<>(); + QueryDefinition queryDef = getQueryDef(); + if (queryDef != null) + { + if (null != getContainerFilter()) + queryDef.setContainerFilter(getContainerFilter()); + ti = queryDef.getTable(getSchema(), errors, true, false); + } + } + + if (ti != null) + cfg = ti.getButtonBarConfig(); + } + + if (cfg != null && cfg.getScriptIncludes() != null) + { + for (String script : cfg.getScriptIncludes()) + { + resources.add(ClientDependency.fromPath(script)); + } + } + + List displayColumns = getDisplayColumns(); + + if (null != displayColumns) + { + for (DisplayColumn dc : displayColumns) + { + resources.addAll(dc.getClientDependencies()); + } + } + + return resources; + } + + public void setMessageSupplier(DataRegion.MessageSupplier messageSupplier) + { + _messageSupplier = messageSupplier; + } +} diff --git a/list/src/org/labkey/list/controllers/ListController.java b/list/src/org/labkey/list/controllers/ListController.java index 0e1633903d3..c6dfd777348 100644 --- a/list/src/org/labkey/list/controllers/ListController.java +++ b/list/src/org/labkey/list/controllers/ListController.java @@ -1,1256 +1,1256 @@ -/* - * Copyright (c) 2013-2023 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.controllers; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletResponse; -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.json.JSONObject; -import org.labkey.api.action.ApiResponse; -import org.labkey.api.action.ApiSimpleResponse; -import org.labkey.api.action.ConfirmAction; -import org.labkey.api.action.ExportAction; -import org.labkey.api.action.ExportException; -import org.labkey.api.action.FormViewAction; -import org.labkey.api.action.Marshal; -import org.labkey.api.action.Marshaller; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.action.ReturnUrlForm; -import org.labkey.api.action.SimpleErrorView; -import org.labkey.api.action.SimpleRedirectAction; -import org.labkey.api.action.SimpleViewAction; -import org.labkey.api.action.SpringActionController; -import org.labkey.api.admin.FolderArchiveDataTypes; -import org.labkey.api.admin.FolderExportContext; -import org.labkey.api.admin.StaticLoggerGetter; -import org.labkey.api.attachments.AttachmentForm; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.BaseDownloadAction; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.audit.view.AuditChangesView; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DataRegionSelection; -import org.labkey.api.data.SQLFragment; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.defaults.ClearDefaultValuesAction; -import org.labkey.api.defaults.DomainIdForm; -import org.labkey.api.defaults.SetDefaultValuesAction; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.list.ListUrls; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainAuditProvider; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.lists.permissions.ManagePicklistsPermission; -import org.labkey.api.module.ModuleHtmlView; -import org.labkey.api.module.ModuleLoader; -import org.labkey.api.query.AbstractQueryImportAction; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryForm; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryUpdateForm; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.UserSchema; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.security.RequiresAnyOf; -import org.labkey.api.security.RequiresPermission; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.security.permissions.InsertPermission; -import org.labkey.api.security.permissions.PlatformDeveloperPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.util.FileStream; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.GUID; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.URLHelper; -import org.labkey.api.util.logging.LogHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DetailsView; -import org.labkey.api.view.HtmlView; -import org.labkey.api.view.HttpPostRedirectView; -import org.labkey.api.view.JspView; -import org.labkey.api.view.NavTree; -import org.labkey.api.view.NotFoundException; -import org.labkey.api.view.RedirectException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.view.VBox; -import org.labkey.api.view.ViewContext; -import org.labkey.api.view.WebPartView; -import org.labkey.api.view.template.PageConfig; -import org.labkey.api.writer.ZipFile; -import org.labkey.list.model.ListAuditProvider; -import org.labkey.list.model.ListDef; -import org.labkey.list.model.ListDefinitionImpl; -import org.labkey.list.model.ListDomainKindProperties; -import org.labkey.list.model.ListManager; -import org.labkey.list.model.ListManagerSchema; -import org.labkey.list.model.ListWriter; -import org.labkey.list.view.ListDefinitionForm; -import org.labkey.list.view.ListItemAttachmentParent; -import org.labkey.list.view.ListQueryForm; -import org.labkey.list.view.ListQueryView; -import org.springframework.beans.PropertyValue; -import org.springframework.validation.BindException; -import org.springframework.validation.Errors; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.ModelAndView; - -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeSet; - -public class ListController extends SpringActionController -{ - private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(ListController.class, ClearDefaultValuesAction.class); - - public ListController() - { - setActionResolver(_actionResolver); - } - - - private void addRootNavTrail(NavTree root) - { - addRootNavTrail(root, getContainer(), getUser()); - } - - public static class ListUrlsImpl implements ListUrls - { - @Override - public ActionURL getManageListsURL(Container c) - { - return new ActionURL(ListController.BeginAction.class, c); - } - - @Override - public ActionURL getCreateListURL(Container c) - { - return new ActionURL(EditListDefinitionAction.class, c); - } - } - - - public static void addRootNavTrail(NavTree root, Container c, User user) - { - if (c.hasOneOf(user, DesignListPermission.class, PlatformDeveloperPermission.class)) - { - root.addChild("Lists", getBeginURL(c)); - } - } - - - private void addListNavTrail(NavTree root, ListDefinition list, @Nullable String title) - { - addRootNavTrail(root); - root.addChild(list.getName(), list.urlShowData()); - - if (null != title) - root.addChild(title); - } - - - public static ActionURL getBeginURL(Container c) - { - return new ActionURL(BeginAction.class, c); - } - - @Override - public PageConfig defaultPageConfig() - { - PageConfig config = super.defaultPageConfig(); - return config.setHelpTopic("lists"); - } - - @RequiresPermission(ReadPermission.class) - public static class BeginAction extends SimpleViewAction - { - @Override - public ModelAndView getView(QueryForm queryForm, BindException errors) - { - UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), ListManagerSchema.SCHEMA_NAME); - QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, ListManagerSchema.LIST_MANAGER); - - // users should see all lists without a category and public picklists and any lists they created. - SimpleFilter filter = new SimpleFilter(); - - SQLFragment sql = new SQLFragment("Category IS NULL OR Category = ") - .appendValue(ListDefinition.Category.PublicPicklist) - .append(" OR CreatedBy = ").appendValue(getUser().getUserId()); - filter.addWhereClause(sql, FieldKey.fromParts("Category"), FieldKey.fromParts("CreatedBy")); - settings.setBaseFilter(filter); - - if (null == StringUtils.trimToNull(settings.getContainerFilterName())) - settings.setContainerFilterName(ContainerFilter.Type.CurrentPlusProjectAndShared.name()); - - return schema.createView(getViewContext(), settings, errors); - } - - @Override - public void addNavTrail(NavTree root) - { - root.addChild("Available Lists"); - } - } - - @RequiresPermission(ReadPermission.class) - public class ShowListDefinitionAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(ListDefinitionForm listDefinitionForm) - { - if (listDefinitionForm.getListId() == null) - { - throw new NotFoundException(); - } - return new ActionURL(EditListDefinitionAction.class, getContainer()).addParameter("listId", listDefinitionForm.getListId().intValue()); - } - } - - @Marshal(Marshaller.Jackson) - @RequiresPermission(ReadPermission.class) - public static class GetListPropertiesAction extends ReadOnlyApiAction - { - @Override - public Object execute(ListDefinitionForm form, BindException errors) throws Exception - { - ListDomainKindProperties properties = ListManager.get().getListDomainKindProperties(getContainer(), form.getListId()); - if (properties != null) - return properties; - else - throw new NotFoundException("List does not exist in this container for listId " + form.getListId() + "."); - } - } - - @RequiresPermission(DesignListPermission.class) - public class EditListDefinitionAction extends SimpleViewAction - { - private ListDefinition _list; - String listDesignerHeader = "List Designer"; - - @Override - public ModelAndView getView(ListDefinitionForm form, BindException errors) - { - _list = null; - boolean createList = (null == form.getListId() || 0 == form.getListId()) && form.getName() == null; - if (!createList) - _list = form.getList(); - - return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("listDesigner")); - } - - @Override - public void addNavTrail(NavTree root) - { - if (null == _list) - { - root.addChild(listDesignerHeader); - } - else - { - addListNavTrail(root, _list, listDesignerHeader); - } - } - } - - @RequiresAnyOf({DesignListPermission.class, ManagePicklistsPermission.class}) - public static class DeleteListDefinitionAction extends ConfirmAction - { - private boolean canDelete(Container listContainer, int listId) - { - ListDef listDef = ListManager.get().getList(listContainer, listId); - ListDefinitionImpl list = ListDefinitionImpl.of(listDef); - - if (list == null) - return false; - - boolean isPicklist = listDef.getCategory() != null; - if (isPicklist) - { - boolean isOwnPicklist = listDef.getCreatedBy() == getUser().getUserId(); - return isOwnPicklist || (listDef.getCategory() == ListDefinition.Category.PublicPicklist && list.getContainer().hasPermission(getUser(), AdminPermission.class)); - } - - return list.getContainer().hasPermission(getUser(), DesignListPermission.class); - } - - @Override - public String getConfirmText() - { - return "Confirm Delete"; - } - - @Override - public void validateCommand(ListDeletionForm form, Errors errors) - { - if (form.getListId() != null) - { - if (canDelete(getContainer(), form.getListId())) - form.getListContainerMap().add(Pair.of(form.getListId(), getContainer())); - else - errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", form.getListId(), getContainer().getName())); - } - else if (form.getName() != null) - { - var list = form.getList(); - if (canDelete(list.getContainer(), list.getListId())) - form.getListContainerMap().add(Pair.of(list.getListId(), getContainer())); - else - errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", list.getListId(), getContainer().getName())); - } - else - { - List errorMessages = new ArrayList<>(); - Collection listIDs; - if (form.getListIds() != null) - listIDs = form.getListIds(); - else - listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); - - for (Pair pair : getListIdContainerPairs(listIDs, getContainer(), errorMessages)) - { - var listId = pair.first; - var listContainer = pair.second; - - if (canDelete(listContainer, listId)) - form.getListContainerMap().add(pair); - else - errorMessages.add(String.format("You do not have permission to delete list %s in container %s", listId, listContainer.getName())); - } - - if (!errorMessages.isEmpty()) - errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); - } - - if (form.getListContainerMap().isEmpty()) - errors.reject(ERROR_MSG, "You must specify a list or lists to delete."); - } - - @Override - public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Confirm Deletion"); - return new JspView<>("/org/labkey/list/view/deleteListDefinition.jsp", form, errors); - } - - @Override - public boolean handlePost(ListDeletionForm form, BindException errors) - { - for (Pair pair : form.getListContainerMap()) - { - ListDefinition listDefinition = ListService.get().getList(pair.second, pair.first); - if (null != listDefinition) - { - try - { - listDefinition.delete(getUser()); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, "Error deleting list '" + listDefinition.getName() + "'; another user may have deleted it."); - } - } - } - - return !errors.hasErrors(); - } - - @Override @NotNull - public URLHelper getSuccessURL(ListDeletionForm form) - { - return form.getReturnUrlHelper(getBeginURL(getContainer())); - } - } - - public static class ListDeletionForm extends ListDefinitionForm - { - private List _listIds; - private final List> _listContainerMap = new ArrayList<>(); - - public List getListIds() - { - return _listIds; - } - - public void setListIds(List listIds) - { - _listIds = listIds; - } - - public List> getListContainerMap() - { - return _listContainerMap; - } - } - - @RequiresPermission(AdminPermission.class) - public static class TruncateListDataAction extends ConfirmAction - { - private boolean canTruncate(Container listContainer, int listId) - { - ListDef listDef = ListManager.get().getList(listContainer, listId); - ListDefinitionImpl list = ListDefinitionImpl.of(listDef); - - if (list == null || !list.getAllowDelete()) - return false; - - return list.getContainer().hasPermission(getUser(), AdminPermission.class); - } - - @Override - public String getConfirmText() - { - return "Confirm Delete All Data"; - } - - @Override - public void validateCommand(ListDeletionForm form, Errors errors) - { - Container currentContainer = getContainer(); - List errorMessages = new ArrayList<>(); - Collection listIDs; - if (form.getListIds() != null) - listIDs = form.getListIds(); - else - listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); - - for (Pair pair : getListIdContainerPairs(listIDs, currentContainer, errorMessages)) - { - var listId = pair.first; - var listContainer = pair.second; - - if (canTruncate(listContainer, listId)) - { - form.getListContainerMap().add(pair); - } - else - errorMessages.add(String.format("You do not have permission to delete data for list %s in container %s", listId, listContainer.getName())); - } - - if (!errorMessages.isEmpty()) - errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); - - if (form.getListContainerMap().isEmpty()) - errors.reject(ERROR_MSG, "You must specify a list or lists to delete data from."); - } - - @Override - public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) - { - if (getPageConfig().getTitle() == null) - setTitle("Confirm Delete All Data"); - return new JspView<>("/org/labkey/list/view/truncateListData.jsp", form, errors); - } - - @Override - public boolean handlePost(ListDeletionForm form, BindException errors) - { - Container containerDataToDelete = getContainer(); - for (Pair pair : form.getListContainerMap()) - { - Container listDefContainer = pair.second; - ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first); - if (null != listDef) - { - try - { - TableInfo table = listDef.getTable(getUser(), listDefContainer); - if (table != null && table.getUpdateService() != null) - table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null); - } - catch (Exception e) - { - errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage()); - } - } - } - - return !errors.hasErrors(); - } - - @Override @NotNull - public URLHelper getSuccessURL(ListDeletionForm form) - { - return form.getReturnUrlHelper(getBeginURL(getContainer())); - } - } - - @RequiresPermission(ReadPermission.class) - public class GridAction extends SimpleViewAction - { - private ListDefinition _list; - private String _title; - - @Override - public ModelAndView getView(ListQueryForm form, BindException errors) - { - _list = form.getList(); - if (null == _list) - throw new NotFoundException("List does not exist in this container"); - - if (!_list.isVisible(getUser())) - throw new UnauthorizedException("User is not allowed to see this list."); - - ListQueryView view = new ListQueryView(form, errors); - - TableInfo ti = view.getTable(); - if (ti != null) - { - _title = ti.getTitle(); - } - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, _title); - } - } - - - public abstract static class InsertUpdateAction extends FormViewAction - { - protected abstract ActionURL getActionView(ListDefinition list, BindException errors); - protected abstract Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues); - - @Override - public void validateCommand(ListDefinitionForm form, Errors errors) - { - /* No-op */ - } - - @Override - public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) - { - ListDefinition list = form.getList(); // throws NotFoundException - - ActionURL url = getActionView(list, errors); - Collection> inputs = getInputs(list, url, getPropertyValues().getPropertyValues()); - - if (getViewContext().getRequest().getMethod().equalsIgnoreCase("POST")) - { - getPageConfig().setTemplate(PageConfig.Template.None); - return new HttpPostRedirectView(url.toString(), inputs); - } - - throw new RedirectException(url); - } - - @Override - public boolean handlePost(ListDefinitionForm form, BindException errors) - { - return true; - } - - @Override - public URLHelper getSuccessURL(ListDefinitionForm form) - { - return null; - } - - @Override - public void addNavTrail(NavTree root) - { - } - } - - /** - * DO NOT USE. This action has been deprecated in 13.2 in favor of the standard query/insertQueryRow action. - * Only here for backwards compatibility to resolve requests and redirect. - */ - @Deprecated - @RequiresPermission(InsertPermission.class) - public class InsertAction extends InsertUpdateAction - { - @Override - protected ActionURL getActionView(ListDefinition list, BindException errors) - { - TableInfo listTable = list.getTable(getUser()); - return listTable.getUserSchema().getQueryDefForTable(listTable.getName()).urlFor(QueryAction.insertQueryRow, getContainer()); - } - - @Override - protected Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues) - { - Collection> inputs = new ArrayList<>(); - - for (PropertyValue value : propertyValues) - { - if (value.getName().equals(ActionURL.Param.returnUrl.toString())) - { - url.addParameter(ActionURL.Param.returnUrl, (String) value.getValue()); - } - else - inputs.add(Pair.of(value.getName(), value.getValue().toString())); - } - - return inputs; - } - } - - public static class ListDetailsForm extends ListDefinitionForm - { - private Object _pk; - - public Object getPk() - { - return _pk; - } - - public void setPk(Object pk) - { - _pk = pk; - } - } - - @RequiresPermission(ReadPermission.class) - public class DetailsAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListDetailsForm form, BindException errors) - { - _list = form.getList(); - TableInfo table = _list.getTable(getUser(), getContainer()); - - if (null == table) - throw new NotFoundException("List does not exist"); - - ListQueryUpdateForm tableForm = new ListQueryUpdateForm(table, getViewContext(), _list, form.getPk(), errors); - DetailsView details = new DetailsView(tableForm); - - ButtonBar bb = new ButtonBar(); - bb.setStyle(ButtonBar.Style.separateButtons); - - ActionButton gridButton; - ActionURL gridUrl = _list.urlShowData(getViewContext().getContainer()); - gridButton = new ActionButton("Show Grid", gridUrl); - - if (table.hasPermission(getUser(), UpdatePermission.class)) - { - ActionURL updateUrl = _list.urlUpdate(getUser(), getContainer(), tableForm.getPkVal(), gridUrl); - ActionButton editButton = new ActionButton("Edit", updateUrl); - bb.add(editButton); - } - - bb.add(gridButton); - details.getDataRegion().setButtonBar(bb); - - VBox view = new VBox(); - ListItem item; - item = _list.getListItem(tableForm.getPkVal(), getUser(), getContainer()); - - if (null == item) - throw new NotFoundException("List item '" + tableForm.getPkVal() + "' does not exist"); - - view.addView(details); - - if (form.isShowHistory()) - { - WebPartView linkView = new HtmlView(LinkBuilder.labkeyLink("hide item history", getViewContext().cloneActionURL().deleteParameter("showHistory")).build()); - linkView.setFrame(WebPartView.FrameType.NONE); - view.addView(linkView); - - UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); - if (schema != null) - { - QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - - SimpleFilter filter = new SimpleFilter(); - filter.addCondition(FieldKey.fromParts(ListAuditProvider.COLUMN_NAME_LIST_ITEM_ENTITY_ID), item.getEntityId()); - - settings.setBaseFilter(filter); - settings.setQueryName(ListManager.LIST_AUDIT_EVENT); - QueryView history = schema.createView(getViewContext(), settings, errors); - - history.setTitle("List Item History:"); - history.setFrame(WebPartView.FrameType.NONE); - view.addView(history); - } - } - else - { - view.addView(new HtmlView(LinkBuilder.labkeyLink("show item history", getViewContext().cloneActionURL().addParameter("showHistory", "1")).build())); - } - - return view; - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, "View List Item"); - } - } - - - // Override to ensure that pk value type matches column type. This is critical for PostgreSQL 8.3. - public static class ListQueryUpdateForm extends QueryUpdateForm - { - private final ListDefinition _list; - private final Object _pk; - - public ListQueryUpdateForm(TableInfo table, ViewContext ctx, ListDefinition list, @Nullable Object pk, BindException errors) - { - super(table, ctx, errors); - _list = list; - _pk = pk; - } - - @Override - public Object[] getPkVals() - { - if (_pk != null) - { - return new Object[]{_pk}; - } - else - { - Object[] pks = super.getPkVals(); - assert 1 == pks.length; - pks[0] = _list.getKeyType().convertKey(pks[0]); - return pks; - } - } - - public Domain getDomain() - { - return _list != null ? _list.getDomain() : null; - } - } - - - // Users can change the PK of a list item, so we don't want to store PK in discussion source URL (back link - // from announcements to the object). Instead, we tell discussion service to store a URL with ListId and - // EntityId. This action resolves to the current details URL for that item. - @RequiresPermission(ReadPermission.class) - public class ResolveAction extends SimpleRedirectAction - { - @Override - public ActionURL getRedirectURL(ListDefinitionForm form) - { - ListDefinition list = form.getList(); - ListItem item = list.getListItemForEntityId(getViewContext().getActionURL().getParameter("entityId"), getUser()); // TODO: Use proper form, validate - ActionURL url = getViewContext().cloneActionURL().setAction(DetailsAction.class); // Clone to preserve discussion params - url.deleteParameter("entityId"); - url.addParameter("pk", item.getKey().toString()); - - return url; - } - } - - - @RequiresPermission(InsertPermission.class) - public class UploadListItemsAction extends AbstractQueryImportAction - { - private ListDefinition _list; - private QueryUpdateService.InsertOption _insertOption; - - @Override - protected void initRequest(ListDefinitionForm form) throws ServletException - { - _list = form.getList(); - _insertOption = form.getInsertOption(); - setTarget(_list.getTableForInsert(getUser(), getContainer())); - } - - @Override - public ModelAndView getView(ListDefinitionForm form, BindException errors) throws Exception - { - initRequest(form); - boolean allowImportOptions = _list.getKeyType() != ListDefinition.KeyType.AutoIncrementInteger; - setShowMergeOption(allowImportOptions); - setShowUpdateOption(allowImportOptions); - setSuccessMessageSuffix("imported"); - return getDefaultImportView(form, errors); - } - - @Override - protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException - { - return _list.importListItems(getUser(), getContainer(), dl, errors, null, null, false, getLookupResolutionType(), _insertOption); - } - - @Override - protected void validatePermission(User user, BindException errors) - { - super.validatePermission(user, errors); - if (!_list.getAllowUpload()) - errors.reject(SpringActionController.ERROR_MSG, "This list does not allow uploading data"); - } - - @Override - public void addNavTrail(NavTree root) - { - addListNavTrail(root, _list, "Import Data"); - } - } - - - @RequiresPermission(ReadPermission.class) - public class HistoryAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListQueryForm form, BindException errors) - { - _list = form.getList(); - if (_list != null) - { - UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); - if (schema != null) - { - VBox box = new VBox(); - String domainUri = _list.getDomain().getTypeURI(); - - // list audit events - QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - SimpleFilter eventFilter = new SimpleFilter(); - eventFilter.addCondition(FieldKey.fromParts(ListManager.LISTID_FIELD_NAME), _list.getListId()); - settings.setBaseFilter(eventFilter); - settings.setQueryName(ListManager.LIST_AUDIT_EVENT); - - QueryView view = schema.createView(getViewContext(), settings, errors); - view.setTitle("List Events"); - box.addView(view); - - // domain audit events associated with this list - QuerySettings domainSettings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); - - SimpleFilter domainFilter = new SimpleFilter(); - domainFilter.addCondition(FieldKey.fromParts(DomainAuditProvider.COLUMN_NAME_DOMAIN_URI), domainUri); - domainSettings.setBaseFilter(domainFilter); - - domainSettings.setQueryName(DomainAuditProvider.EVENT_TYPE); - QueryView domainView = schema.createView(getViewContext(), domainSettings, errors); - - domainView.setTitle("List Design Changes"); - box.addView(domainView); - - return box; - } - return HtmlView.of("Unable to create the List history view"); - } - else - return HtmlView.of("Unable to find the specified List"); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_list != null) - addListNavTrail(root, _list, _list.getName() + ":History"); - else - root.addChild(":History"); - } - } - - private String getUrlParam(Enum param) - { - String s = getViewContext().getActionURL().getParameter(param); - ReturnUrlForm form = new ReturnUrlForm(); - form.setReturnUrl(s); - return form.getReturnUrl(); - } - - public static class ListItemDetailsForm - { - private Integer _listId; - private String _name; - private Integer _rowId; - - public Integer getListId() - { - return _listId; - } - - public void setListId(Integer listId) - { - _listId = listId; - } - - public String getName() - { - return _name; - } - - public void setName(String name) - { - _name = name; - } - - public Integer getRowId() - { - return _rowId; - } - - public void setRowId(Integer rowId) - { - _rowId = rowId; - } - } - - @RequiresPermission(ReadPermission.class) - public class ListItemDetailsAction extends SimpleViewAction - { - private ListDefinition _list; - - @Override - public ModelAndView getView(ListItemDetailsForm form, BindException errors) - { - String listName = form.getName(); - if (listName != null) - _list = ListService.get().getList(getContainer(), listName, true); - - if (_list == null) - { - Integer listId = form.getListId(); - if (listId != null && listId > 0) - _list = ListService.get().getList(getContainer(), listId); - } - - if (_list == null) - return HtmlView.of("This list is no longer available."); - - String comment = null; - String oldRecord = null; - String newRecord = null; - - Integer eventRowId = form.getRowId(); - if (eventRowId == null || eventRowId <= 0) - return HtmlView.of("Unable to resolve event details. An event \"rowId\" must be specified."); - - ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), ListManager.LIST_AUDIT_EVENT, eventRowId); - - if (event != null) - { - comment = event.getComment(); - oldRecord = event.getOldRecordMap(); - newRecord = event.getNewRecordMap(); - } - - if (!StringUtils.isEmpty(oldRecord) || !StringUtils.isEmpty(newRecord)) - { - Map oldData = ListAuditProvider.decodeFromDataMap(oldRecord); - Map newData = ListAuditProvider.decodeFromDataMap(newRecord); - - String srcUrl = getUrlParam(ActionURL.Param.redirectUrl); - if (srcUrl == null) - srcUrl = getUrlParam(ActionURL.Param.returnUrl); - if (srcUrl == null) - srcUrl = _list.urlFor(ListController.HistoryAction.class, getContainer()).getLocalURIString(); - AuditChangesView view = new AuditChangesView(comment, oldData, newData); - view.setReturnUrl(srcUrl); - - return view; - } - else - return HtmlView.of("No details available for this event."); - } - - @Override - public void addNavTrail(NavTree root) - { - if (_list != null) - addListNavTrail(root, _list, "List Item Details"); - else - root.addChild("List Item Details"); - } - } - - - public static class ListAttachmentForm extends AttachmentForm - { - private int _listId; - - public int getListId() - { - return _listId; - } - - public void setListId(int listId) - { - _listId = listId; - } - } - - - public static ActionURL getDownloadURL(ListDefinition list, String rowEntityId, String name) - { - return new ActionURL(DownloadAction.class, list.getContainer()) - .addParameter("listId", list.getListId()) - .addParameter("entityId", rowEntityId) - .addParameter("name", name); - } - - @RequiresPermission(ReadPermission.class) - public static class DownloadAction extends BaseDownloadAction - { - @Override - public void validate(ListAttachmentForm form, BindException errors) - { - if (!GUID.isGUID(form.getEntityId())) - { - errors.rejectValue("entityId", ERROR_MSG, "entityId is not a GUID: " + form.getEntityId()); - } - } - - @Nullable - @Override - public Pair getAttachment(ListAttachmentForm form) - { - ListDefinitionImpl listDef = (ListDefinitionImpl)ListService.get().getList(getContainer(), form.getListId()); - if (listDef == null) - throw new NotFoundException("List does not exist in this container"); - - Container dataContainer = listDef.getListItemContainerForDownload(form.getEntityId(), getUser(), ReadPermission.class); - if (dataContainer == null) - throw new NotFoundException("List does not have an item for the entityid"); - - AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), dataContainer); - - return new Pair<>(parent, form.getName()); - } - } - - - @RequiresPermission(DesignListPermission.class) - public static class ExportListArchiveAction extends ExportAction - { - @Override - public void export(ListDefinitionForm form, HttpServletResponse response, BindException errors) throws Exception - { - Container c = getContainer(); - List errorMessages = new ArrayList<>(); - Set selection = DataRegionSelection.getSelected(form.getViewContext(), false); - List> selectedLists = new LinkedList<>(); - Map duplicateNames = new HashMap<>(); - - for (Pair pair : getListIdContainerPairs(selection, c, errorMessages)) - { - String listName = Objects.requireNonNull(ListManager.get().getList(pair.second, pair.first)).getName(); - - //Display simple error to the user when Lists with the same names are selected. - if (duplicateNames.containsKey(listName)) - { - errors.reject(ERROR_MSG, "'" + listName + "' is already selected, please select Lists with unique names to Export."); - throw new ExportException(new SimpleErrorView(errors, true)); - } - else - { - duplicateNames.put(listName, pair.first); - } - // Issue 47289: Export List Archive if the user is an Admin of the folders of the selected Lists, else throw Permission error - if (!pair.second.hasPermission(getUser(), DesignListPermission.class)) - { - errors.reject(ERROR_MSG, String.format("List archive export is only supported for Lists in folders where you are an administrator. Try filtering to select only Lists in the local folder.")); - throw new ExportException(new SimpleErrorView(errors, true)); - } - selectedLists.add(pair); - } - - Set dataTypes = PageFlowUtil.set(FolderArchiveDataTypes.LIST_DESIGN, FolderArchiveDataTypes.LIST_DATA); - FolderExportContext ctx = new FolderExportContext(getUser(), c, dataTypes, "List Export", new StaticLoggerGetter(LogHelper.getLogger(ListController.class, "Export List Archive"))); - ctx.setLists(selectedLists); - ListWriter writer = new ListWriter(); - - // Export to a temporary file first so exceptions are displayed by the standard error page, Issue #44152 - // Same pattern as ExportFolderAction - Path tempDir = FileUtil.getTempDirectory().toPath(); - String filename = FileUtil.makeFileNameWithTimestamp(c.getName(), "lists.zip"); - - try (ZipFile zip = new ZipFile(tempDir, filename)) - { - writer.write(getUser(), zip, ctx); - } - - Path tempZipFile = tempDir.resolve(filename); - - // No exceptions, so stream the resulting zip file to the browser and delete it - try (OutputStream os = ZipFile.getOutputStream(getViewContext().getResponse(), filename)) - { - Files.copy(tempZipFile, os); - } - finally - { - Files.delete(tempZipFile); - } - } - } - - - @RequiresPermission(DesignListPermission.class) - public class ImportListArchiveAction extends FormViewAction - { - @Override - public void validateCommand(ListDefinitionForm target, Errors errors) - { - } - - @Override - public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) - { - return new JspView<>("/org/labkey/list/view/importLists.jsp", null, errors); - } - - @Override - public boolean handlePost(ListDefinitionForm form, BindException errors) throws Exception - { - Map map = getFileMap(); - - if (map.isEmpty()) - { - errors.reject("listImport", "You must select a .list.zip file to import."); - } - else if (map.size() > 1) - { - errors.reject("listImport", "Only one file is allowed."); - } - else - { - MultipartFile file = map.values().iterator().next(); - - if (0 == file.getSize() || StringUtils.isBlank(file.getOriginalFilename())) - { - errors.reject("listImport", "You must select a .list.zip file to import."); - } - else - { - ListService.get().importListArchive(file.getInputStream(), errors, getContainer(), getUser()); - } - } - - return !errors.hasErrors(); - } - - @Override - public ActionURL getSuccessURL(ListDefinitionForm form) - { - return form.getReturnActionURL( getBeginURL(getContainer())); - } - - @Override - public void addNavTrail(NavTree root) - { - addRootNavTrail(root); - root.addChild("Import List Archive"); - } - } - - @RequiresPermission(ReadPermission.class) - public static class BrowseListsAction extends ReadOnlyApiAction - { - @Override - public ApiResponse execute(Object form, BindException errors) - { - ApiSimpleResponse response = new ApiSimpleResponse(); - - response.put("lists", getJSONLists(ListService.get().getLists(getContainer(), getUser(), true))); - response.put("success", true); - - return response; - } - - private List getJSONLists(Map lists){ - List listsJSON = new ArrayList<>(); - for(ListDefinition def : new TreeSet<>(lists.values())){ - JSONObject listObj = new JSONObject(); - listObj.put("name", def.getName()); - listObj.put("id", def.getListId()); - listObj.put("description", def.getDescription()); - listsJSON.add(listObj); - } - return listsJSON; - } - } - - @RequiresPermission(DesignListPermission.class) - public static class SetDefaultValuesListAction extends SetDefaultValuesAction - { - } - - /** - * Utility method to parse out Pair from a Collection where the strings are encoded - * pairs of listIds and container entityIds separated (e.g. "12,ff72c81e-ce2d-103a-b3ce-e8f660509016"). - */ - private static List> getListIdContainerPairs( - Collection listIdContainers, - Container currentContainer, - Collection errors) - { - List> pairs = new ArrayList<>(); - - for (String s : listIdContainers) - { - String[] parts = s.split(","); - Container c; - if (parts.length > 1) - c = ContainerManager.getForId(parts[1]); - else - c = currentContainer; - if (c == null) - { - errors.add(String.format("Container not found for %s", s)); - continue; - } - - try - { - int listId = Integer.parseInt(parts[0]); - pairs.add(Pair.of(listId, c)); - } - catch (NumberFormatException badListId) - { - errors.add(String.format("Invalid listId: %s", s)); - } - } - - return pairs; - } -} +/* + * Copyright (c) 2013-2023 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.controllers; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.json.JSONObject; +import org.labkey.api.action.ApiResponse; +import org.labkey.api.action.ApiSimpleResponse; +import org.labkey.api.action.ConfirmAction; +import org.labkey.api.action.ExportAction; +import org.labkey.api.action.ExportException; +import org.labkey.api.action.FormViewAction; +import org.labkey.api.action.Marshal; +import org.labkey.api.action.Marshaller; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.action.ReturnUrlForm; +import org.labkey.api.action.SimpleErrorView; +import org.labkey.api.action.SimpleRedirectAction; +import org.labkey.api.action.SimpleViewAction; +import org.labkey.api.action.SpringActionController; +import org.labkey.api.admin.FolderArchiveDataTypes; +import org.labkey.api.admin.FolderExportContext; +import org.labkey.api.admin.StaticLoggerGetter; +import org.labkey.api.attachments.AttachmentForm; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.BaseDownloadAction; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.audit.view.AuditChangesView; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.SQLFragment; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.defaults.ClearDefaultValuesAction; +import org.labkey.api.defaults.DomainIdForm; +import org.labkey.api.defaults.SetDefaultValuesAction; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.list.ListUrls; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainAuditProvider; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.lists.permissions.ManagePicklistsPermission; +import org.labkey.api.module.ModuleHtmlView; +import org.labkey.api.module.ModuleLoader; +import org.labkey.api.query.AbstractQueryImportAction; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryForm; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryUpdateForm; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.UserSchema; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.security.RequiresAnyOf; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.security.permissions.InsertPermission; +import org.labkey.api.security.permissions.PlatformDeveloperPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.util.FileStream; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.GUID; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.URLHelper; +import org.labkey.api.util.logging.LogHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DetailsView; +import org.labkey.api.view.HtmlView; +import org.labkey.api.view.HttpPostRedirectView; +import org.labkey.api.view.JspView; +import org.labkey.api.view.NavTree; +import org.labkey.api.view.NotFoundException; +import org.labkey.api.view.RedirectException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; +import org.labkey.api.view.WebPartView; +import org.labkey.api.view.template.PageConfig; +import org.labkey.api.writer.ZipFile; +import org.labkey.list.model.ListAuditProvider; +import org.labkey.list.model.ListDef; +import org.labkey.list.model.ListDefinitionImpl; +import org.labkey.list.model.ListDomainKindProperties; +import org.labkey.list.model.ListManager; +import org.labkey.list.model.ListManagerSchema; +import org.labkey.list.model.ListWriter; +import org.labkey.list.view.ListDefinitionForm; +import org.labkey.list.view.ListItemAttachmentParent; +import org.labkey.list.view.ListQueryForm; +import org.labkey.list.view.ListQueryView; +import org.springframework.beans.PropertyValue; +import org.springframework.validation.BindException; +import org.springframework.validation.Errors; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.ModelAndView; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +public class ListController extends SpringActionController +{ + private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(ListController.class, ClearDefaultValuesAction.class); + + public ListController() + { + setActionResolver(_actionResolver); + } + + + private void addRootNavTrail(NavTree root) + { + addRootNavTrail(root, getContainer(), getUser()); + } + + public static class ListUrlsImpl implements ListUrls + { + @Override + public ActionURL getManageListsURL(Container c) + { + return new ActionURL(ListController.BeginAction.class, c); + } + + @Override + public ActionURL getCreateListURL(Container c) + { + return new ActionURL(EditListDefinitionAction.class, c); + } + } + + + public static void addRootNavTrail(NavTree root, Container c, User user) + { + if (c.hasOneOf(user, DesignListPermission.class, PlatformDeveloperPermission.class)) + { + root.addChild("Lists", getBeginURL(c)); + } + } + + + private void addListNavTrail(NavTree root, ListDefinition list, @Nullable String title) + { + addRootNavTrail(root); + root.addChild(list.getName(), list.urlShowData()); + + if (null != title) + root.addChild(title); + } + + + public static ActionURL getBeginURL(Container c) + { + return new ActionURL(BeginAction.class, c); + } + + @Override + public PageConfig defaultPageConfig() + { + PageConfig config = super.defaultPageConfig(); + return config.setHelpTopic("lists"); + } + + @RequiresPermission(ReadPermission.class) + public static class BeginAction extends SimpleViewAction + { + @Override + public ModelAndView getView(QueryForm queryForm, BindException errors) + { + UserSchema schema = QueryService.get().getUserSchema(getUser(), getContainer(), ListManagerSchema.SCHEMA_NAME); + QuerySettings settings = schema.getSettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT, ListManagerSchema.LIST_MANAGER); + + // users should see all lists without a category and public picklists and any lists they created. + SimpleFilter filter = new SimpleFilter(); + + SQLFragment sql = new SQLFragment("Category IS NULL OR Category = ") + .appendValue(ListDefinition.Category.PublicPicklist) + .append(" OR CreatedBy = ").appendValue(getUser().getUserId()); + filter.addWhereClause(sql, FieldKey.fromParts("Category"), FieldKey.fromParts("CreatedBy")); + settings.setBaseFilter(filter); + + if (null == StringUtils.trimToNull(settings.getContainerFilterName())) + settings.setContainerFilterName(ContainerFilter.Type.CurrentPlusProjectAndShared.name()); + + return schema.createView(getViewContext(), settings, errors); + } + + @Override + public void addNavTrail(NavTree root) + { + root.addChild("Available Lists"); + } + } + + @RequiresPermission(ReadPermission.class) + public class ShowListDefinitionAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(ListDefinitionForm listDefinitionForm) + { + if (listDefinitionForm.getListId() == null) + { + throw new NotFoundException(); + } + return new ActionURL(EditListDefinitionAction.class, getContainer()).addParameter("listId", listDefinitionForm.getListId().intValue()); + } + } + + @Marshal(Marshaller.Jackson) + @RequiresPermission(ReadPermission.class) + public static class GetListPropertiesAction extends ReadOnlyApiAction + { + @Override + public Object execute(ListDefinitionForm form, BindException errors) throws Exception + { + ListDomainKindProperties properties = ListManager.get().getListDomainKindProperties(getContainer(), form.getListId()); + if (properties != null) + return properties; + else + throw new NotFoundException("List does not exist in this container for listId " + form.getListId() + "."); + } + } + + @RequiresPermission(DesignListPermission.class) + public class EditListDefinitionAction extends SimpleViewAction + { + private ListDefinition _list; + String listDesignerHeader = "List Designer"; + + @Override + public ModelAndView getView(ListDefinitionForm form, BindException errors) + { + _list = null; + boolean createList = (null == form.getListId() || 0 == form.getListId()) && form.getName() == null; + if (!createList) + _list = form.getList(); + + return ModuleHtmlView.get(ModuleLoader.getInstance().getModule("core"), ModuleHtmlView.getGeneratedViewPath("listDesigner")); + } + + @Override + public void addNavTrail(NavTree root) + { + if (null == _list) + { + root.addChild(listDesignerHeader); + } + else + { + addListNavTrail(root, _list, listDesignerHeader); + } + } + } + + @RequiresAnyOf({DesignListPermission.class, ManagePicklistsPermission.class}) + public static class DeleteListDefinitionAction extends ConfirmAction + { + private boolean canDelete(Container listContainer, int listId) + { + ListDef listDef = ListManager.get().getList(listContainer, listId); + ListDefinitionImpl list = ListDefinitionImpl.of(listDef); + + if (list == null) + return false; + + boolean isPicklist = listDef.getCategory() != null; + if (isPicklist) + { + boolean isOwnPicklist = listDef.getCreatedBy() == getUser().getUserId(); + return isOwnPicklist || (listDef.getCategory() == ListDefinition.Category.PublicPicklist && list.getContainer().hasPermission(getUser(), AdminPermission.class)); + } + + return list.getContainer().hasPermission(getUser(), DesignListPermission.class); + } + + @Override + public String getConfirmText() + { + return "Confirm Delete"; + } + + @Override + public void validateCommand(ListDeletionForm form, Errors errors) + { + if (form.getListId() != null) + { + if (canDelete(getContainer(), form.getListId())) + form.getListContainerMap().add(Pair.of(form.getListId(), getContainer())); + else + errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", form.getListId(), getContainer().getName())); + } + else if (form.getName() != null) + { + var list = form.getList(); + if (canDelete(list.getContainer(), list.getListId())) + form.getListContainerMap().add(Pair.of(list.getListId(), getContainer())); + else + errors.reject(ERROR_MSG, String.format("You do not have permission to delete list %s in container %s", list.getListId(), getContainer().getName())); + } + else + { + List errorMessages = new ArrayList<>(); + Collection listIDs; + if (form.getListIds() != null) + listIDs = form.getListIds(); + else + listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); + + for (Pair pair : getListIdContainerPairs(listIDs, getContainer(), errorMessages)) + { + var listId = pair.first; + var listContainer = pair.second; + + if (canDelete(listContainer, listId)) + form.getListContainerMap().add(pair); + else + errorMessages.add(String.format("You do not have permission to delete list %s in container %s", listId, listContainer.getName())); + } + + if (!errorMessages.isEmpty()) + errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); + } + + if (form.getListContainerMap().isEmpty()) + errors.reject(ERROR_MSG, "You must specify a list or lists to delete."); + } + + @Override + public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Confirm Deletion"); + return new JspView<>("/org/labkey/list/view/deleteListDefinition.jsp", form, errors); + } + + @Override + public boolean handlePost(ListDeletionForm form, BindException errors) + { + for (Pair pair : form.getListContainerMap()) + { + ListDefinition listDefinition = ListService.get().getList(pair.second, pair.first); + if (null != listDefinition) + { + try + { + listDefinition.delete(getUser()); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, "Error deleting list '" + listDefinition.getName() + "'; another user may have deleted it."); + } + } + } + + return !errors.hasErrors(); + } + + @Override @NotNull + public URLHelper getSuccessURL(ListDeletionForm form) + { + return form.getReturnUrlHelper(getBeginURL(getContainer())); + } + } + + public static class ListDeletionForm extends ListDefinitionForm + { + private List _listIds; + private final List> _listContainerMap = new ArrayList<>(); + + public List getListIds() + { + return _listIds; + } + + public void setListIds(List listIds) + { + _listIds = listIds; + } + + public List> getListContainerMap() + { + return _listContainerMap; + } + } + + @RequiresPermission(AdminPermission.class) + public static class TruncateListDataAction extends ConfirmAction + { + private boolean canTruncate(Container listContainer, int listId) + { + ListDef listDef = ListManager.get().getList(listContainer, listId); + ListDefinitionImpl list = ListDefinitionImpl.of(listDef); + + if (list == null || !list.getAllowDelete()) + return false; + + return list.getContainer().hasPermission(getUser(), AdminPermission.class); + } + + @Override + public String getConfirmText() + { + return "Confirm Delete All Data"; + } + + @Override + public void validateCommand(ListDeletionForm form, Errors errors) + { + Container currentContainer = getContainer(); + List errorMessages = new ArrayList<>(); + Collection listIDs; + if (form.getListIds() != null) + listIDs = form.getListIds(); + else + listIDs = DataRegionSelection.getSelected(form.getViewContext(), true); + + for (Pair pair : getListIdContainerPairs(listIDs, currentContainer, errorMessages)) + { + var listId = pair.first; + var listContainer = pair.second; + + if (canTruncate(listContainer, listId)) + { + form.getListContainerMap().add(pair); + } + else + errorMessages.add(String.format("You do not have permission to delete data for list %s in container %s", listId, listContainer.getName())); + } + + if (!errorMessages.isEmpty()) + errors.reject(ERROR_MSG, StringUtils.join(errorMessages, "\n")); + + if (form.getListContainerMap().isEmpty()) + errors.reject(ERROR_MSG, "You must specify a list or lists to delete data from."); + } + + @Override + public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) + { + if (getPageConfig().getTitle() == null) + setTitle("Confirm Delete All Data"); + return new JspView<>("/org/labkey/list/view/truncateListData.jsp", form, errors); + } + + @Override + public boolean handlePost(ListDeletionForm form, BindException errors) + { + Container containerDataToDelete = getContainer(); + for (Pair pair : form.getListContainerMap()) + { + Container listDefContainer = pair.second; + ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first); + if (null != listDef) + { + try + { + TableInfo table = listDef.getTable(getUser(), listDefContainer); + if (table != null && table.getUpdateService() != null) + table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage()); + } + } + } + + return !errors.hasErrors(); + } + + @Override @NotNull + public URLHelper getSuccessURL(ListDeletionForm form) + { + return form.getReturnUrlHelper(getBeginURL(getContainer())); + } + } + + @RequiresPermission(ReadPermission.class) + public class GridAction extends SimpleViewAction + { + private ListDefinition _list; + private String _title; + + @Override + public ModelAndView getView(ListQueryForm form, BindException errors) + { + _list = form.getList(); + if (null == _list) + throw new NotFoundException("List does not exist in this container"); + + if (!_list.isVisible(getUser())) + throw new UnauthorizedException("User is not allowed to see this list."); + + ListQueryView view = new ListQueryView(form, errors); + + TableInfo ti = view.getTable(); + if (ti != null) + { + _title = ti.getTitle(); + } + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, _title); + } + } + + + public abstract static class InsertUpdateAction extends FormViewAction + { + protected abstract ActionURL getActionView(ListDefinition list, BindException errors); + protected abstract Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues); + + @Override + public void validateCommand(ListDefinitionForm form, Errors errors) + { + /* No-op */ + } + + @Override + public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) + { + ListDefinition list = form.getList(); // throws NotFoundException + + ActionURL url = getActionView(list, errors); + Collection> inputs = getInputs(list, url, getPropertyValues().getPropertyValues()); + + if (getViewContext().getRequest().getMethod().equalsIgnoreCase("POST")) + { + getPageConfig().setTemplate(PageConfig.Template.None); + return new HttpPostRedirectView(url.toString(), inputs); + } + + throw new RedirectException(url); + } + + @Override + public boolean handlePost(ListDefinitionForm form, BindException errors) + { + return true; + } + + @Override + public URLHelper getSuccessURL(ListDefinitionForm form) + { + return null; + } + + @Override + public void addNavTrail(NavTree root) + { + } + } + + /** + * DO NOT USE. This action has been deprecated in 13.2 in favor of the standard query/insertQueryRow action. + * Only here for backwards compatibility to resolve requests and redirect. + */ + @Deprecated + @RequiresPermission(InsertPermission.class) + public class InsertAction extends InsertUpdateAction + { + @Override + protected ActionURL getActionView(ListDefinition list, BindException errors) + { + TableInfo listTable = list.getTable(getUser()); + return listTable.getUserSchema().getQueryDefForTable(listTable.getName()).urlFor(QueryAction.insertQueryRow, getContainer()); + } + + @Override + protected Collection> getInputs(ListDefinition list, ActionURL url, PropertyValue[] propertyValues) + { + Collection> inputs = new ArrayList<>(); + + for (PropertyValue value : propertyValues) + { + if (value.getName().equals(ActionURL.Param.returnUrl.toString())) + { + url.addParameter(ActionURL.Param.returnUrl, (String) value.getValue()); + } + else + inputs.add(Pair.of(value.getName(), value.getValue().toString())); + } + + return inputs; + } + } + + public static class ListDetailsForm extends ListDefinitionForm + { + private Object _pk; + + public Object getPk() + { + return _pk; + } + + public void setPk(Object pk) + { + _pk = pk; + } + } + + @RequiresPermission(ReadPermission.class) + public class DetailsAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListDetailsForm form, BindException errors) + { + _list = form.getList(); + TableInfo table = _list.getTable(getUser(), getContainer()); + + if (null == table) + throw new NotFoundException("List does not exist"); + + ListQueryUpdateForm tableForm = new ListQueryUpdateForm(table, getViewContext(), _list, form.getPk(), errors); + DetailsView details = new DetailsView(tableForm); + + ButtonBar bb = new ButtonBar(); + bb.setStyle(ButtonBar.Style.separateButtons); + + ActionButton gridButton; + ActionURL gridUrl = _list.urlShowData(getViewContext().getContainer()); + gridButton = new ActionButton("Show Grid", gridUrl); + + if (table.hasPermission(getUser(), UpdatePermission.class)) + { + ActionURL updateUrl = _list.urlUpdate(getUser(), getContainer(), tableForm.getPkVal(), gridUrl); + ActionButton editButton = new ActionButton("Edit", updateUrl); + bb.add(editButton); + } + + bb.add(gridButton); + details.getDataRegion().setButtonBar(bb); + + VBox view = new VBox(); + ListItem item; + item = _list.getListItem(tableForm.getPkVal(), getUser(), getContainer()); + + if (null == item) + throw new NotFoundException("List item '" + tableForm.getPkVal() + "' does not exist"); + + view.addView(details); + + if (form.isShowHistory()) + { + WebPartView linkView = new HtmlView(LinkBuilder.labkeyLink("hide item history", getViewContext().cloneActionURL().deleteParameter("showHistory")).build()); + linkView.setFrame(WebPartView.FrameType.NONE); + view.addView(linkView); + + UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); + if (schema != null) + { + QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + + SimpleFilter filter = new SimpleFilter(); + filter.addCondition(FieldKey.fromParts(ListAuditProvider.COLUMN_NAME_LIST_ITEM_ENTITY_ID), item.getEntityId()); + + settings.setBaseFilter(filter); + settings.setQueryName(ListManager.LIST_AUDIT_EVENT); + QueryView history = schema.createView(getViewContext(), settings, errors); + + history.setTitle("List Item History:"); + history.setFrame(WebPartView.FrameType.NONE); + view.addView(history); + } + } + else + { + view.addView(new HtmlView(LinkBuilder.labkeyLink("show item history", getViewContext().cloneActionURL().addParameter("showHistory", "1")).build())); + } + + return view; + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, "View List Item"); + } + } + + + // Override to ensure that pk value type matches column type. This is critical for PostgreSQL 8.3. + public static class ListQueryUpdateForm extends QueryUpdateForm + { + private final ListDefinition _list; + private final Object _pk; + + public ListQueryUpdateForm(TableInfo table, ViewContext ctx, ListDefinition list, @Nullable Object pk, BindException errors) + { + super(table, ctx, errors); + _list = list; + _pk = pk; + } + + @Override + public Object[] getPkVals() + { + if (_pk != null) + { + return new Object[]{_pk}; + } + else + { + Object[] pks = super.getPkVals(); + assert 1 == pks.length; + pks[0] = _list.getKeyType().convertKey(pks[0]); + return pks; + } + } + + public Domain getDomain() + { + return _list != null ? _list.getDomain() : null; + } + } + + + // Users can change the PK of a list item, so we don't want to store PK in discussion source URL (back link + // from announcements to the object). Instead, we tell discussion service to store a URL with ListId and + // EntityId. This action resolves to the current details URL for that item. + @RequiresPermission(ReadPermission.class) + public class ResolveAction extends SimpleRedirectAction + { + @Override + public ActionURL getRedirectURL(ListDefinitionForm form) + { + ListDefinition list = form.getList(); + ListItem item = list.getListItemForEntityId(getViewContext().getActionURL().getParameter("entityId"), getUser()); // TODO: Use proper form, validate + ActionURL url = getViewContext().cloneActionURL().setAction(DetailsAction.class); // Clone to preserve discussion params + url.deleteParameter("entityId"); + url.addParameter("pk", item.getKey().toString()); + + return url; + } + } + + + @RequiresPermission(InsertPermission.class) + public class UploadListItemsAction extends AbstractQueryImportAction + { + private ListDefinition _list; + private QueryUpdateService.InsertOption _insertOption; + + @Override + protected void initRequest(ListDefinitionForm form) throws ServletException + { + _list = form.getList(); + _insertOption = form.getInsertOption(); + setTarget(_list.getTableForInsert(getUser(), getContainer())); + } + + @Override + public ModelAndView getView(ListDefinitionForm form, BindException errors) throws Exception + { + initRequest(form); + boolean allowImportOptions = _list.getKeyType() != ListDefinition.KeyType.AutoIncrementInteger; + setShowMergeOption(allowImportOptions); + setShowUpdateOption(allowImportOptions); + setSuccessMessageSuffix("imported"); + return getDefaultImportView(form, errors); + } + + @Override + protected int importData(DataLoader dl, FileStream file, String originalName, BatchValidationException errors, @Nullable AuditBehaviorType auditBehaviorType, @Nullable TransactionAuditProvider.TransactionAuditEvent auditEvent, @Nullable String auditUserComment) throws IOException + { + return _list.importListItems(getUser(), getContainer(), dl, errors, null, null, false, getLookupResolutionType(), _insertOption); + } + + @Override + protected void validatePermission(User user, BindException errors) + { + super.validatePermission(user, errors); + if (!_list.getAllowUpload()) + errors.reject(SpringActionController.ERROR_MSG, "This list does not allow uploading data"); + } + + @Override + public void addNavTrail(NavTree root) + { + addListNavTrail(root, _list, "Import Data"); + } + } + + + @RequiresPermission(ReadPermission.class) + public class HistoryAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListQueryForm form, BindException errors) + { + _list = form.getList(); + if (_list != null) + { + UserSchema schema = AuditLogService.getAuditLogSchema(getUser(), getContainer()); + if (schema != null) + { + VBox box = new VBox(); + String domainUri = _list.getDomain().getTypeURI(); + + // list audit events + QuerySettings settings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + SimpleFilter eventFilter = new SimpleFilter(); + eventFilter.addCondition(FieldKey.fromParts(ListManager.LISTID_FIELD_NAME), _list.getListId()); + settings.setBaseFilter(eventFilter); + settings.setQueryName(ListManager.LIST_AUDIT_EVENT); + + QueryView view = schema.createView(getViewContext(), settings, errors); + view.setTitle("List Events"); + box.addView(view); + + // domain audit events associated with this list + QuerySettings domainSettings = new QuerySettings(getViewContext(), QueryView.DATAREGIONNAME_DEFAULT); + + SimpleFilter domainFilter = new SimpleFilter(); + domainFilter.addCondition(FieldKey.fromParts(DomainAuditProvider.COLUMN_NAME_DOMAIN_URI), domainUri); + domainSettings.setBaseFilter(domainFilter); + + domainSettings.setQueryName(DomainAuditProvider.EVENT_TYPE); + QueryView domainView = schema.createView(getViewContext(), domainSettings, errors); + + domainView.setTitle("List Design Changes"); + box.addView(domainView); + + return box; + } + return HtmlView.of("Unable to create the List history view"); + } + else + return HtmlView.of("Unable to find the specified List"); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_list != null) + addListNavTrail(root, _list, _list.getName() + ":History"); + else + root.addChild(":History"); + } + } + + private String getUrlParam(Enum param) + { + String s = getViewContext().getActionURL().getParameter(param); + ReturnUrlForm form = new ReturnUrlForm(); + form.setReturnUrl(s); + return form.getReturnUrl(); + } + + public static class ListItemDetailsForm + { + private Integer _listId; + private String _name; + private Integer _rowId; + + public Integer getListId() + { + return _listId; + } + + public void setListId(Integer listId) + { + _listId = listId; + } + + public String getName() + { + return _name; + } + + public void setName(String name) + { + _name = name; + } + + public Integer getRowId() + { + return _rowId; + } + + public void setRowId(Integer rowId) + { + _rowId = rowId; + } + } + + @RequiresPermission(ReadPermission.class) + public class ListItemDetailsAction extends SimpleViewAction + { + private ListDefinition _list; + + @Override + public ModelAndView getView(ListItemDetailsForm form, BindException errors) + { + String listName = form.getName(); + if (listName != null) + _list = ListService.get().getList(getContainer(), listName, true); + + if (_list == null) + { + Integer listId = form.getListId(); + if (listId != null && listId > 0) + _list = ListService.get().getList(getContainer(), listId); + } + + if (_list == null) + return HtmlView.of("This list is no longer available."); + + String comment = null; + String oldRecord = null; + String newRecord = null; + + Integer eventRowId = form.getRowId(); + if (eventRowId == null || eventRowId <= 0) + return HtmlView.of("Unable to resolve event details. An event \"rowId\" must be specified."); + + ListAuditProvider.ListAuditEvent event = AuditLogService.get().getAuditEvent(getUser(), ListManager.LIST_AUDIT_EVENT, eventRowId); + + if (event != null) + { + comment = event.getComment(); + oldRecord = event.getOldRecordMap(); + newRecord = event.getNewRecordMap(); + } + + if (!StringUtils.isEmpty(oldRecord) || !StringUtils.isEmpty(newRecord)) + { + Map oldData = ListAuditProvider.decodeFromDataMap(oldRecord); + Map newData = ListAuditProvider.decodeFromDataMap(newRecord); + + String srcUrl = getUrlParam(ActionURL.Param.redirectUrl); + if (srcUrl == null) + srcUrl = getUrlParam(ActionURL.Param.returnUrl); + if (srcUrl == null) + srcUrl = _list.urlFor(ListController.HistoryAction.class, getContainer()).getLocalURIString(); + AuditChangesView view = new AuditChangesView(comment, oldData, newData); + view.setReturnUrl(srcUrl); + + return view; + } + else + return HtmlView.of("No details available for this event."); + } + + @Override + public void addNavTrail(NavTree root) + { + if (_list != null) + addListNavTrail(root, _list, "List Item Details"); + else + root.addChild("List Item Details"); + } + } + + + public static class ListAttachmentForm extends AttachmentForm + { + private int _listId; + + public int getListId() + { + return _listId; + } + + public void setListId(int listId) + { + _listId = listId; + } + } + + + public static ActionURL getDownloadURL(ListDefinition list, String rowEntityId, String name) + { + return new ActionURL(DownloadAction.class, list.getContainer()) + .addParameter("listId", list.getListId()) + .addParameter("entityId", rowEntityId) + .addParameter("name", name); + } + + @RequiresPermission(ReadPermission.class) + public static class DownloadAction extends BaseDownloadAction + { + @Override + public void validate(ListAttachmentForm form, BindException errors) + { + if (!GUID.isGUID(form.getEntityId())) + { + errors.rejectValue("entityId", ERROR_MSG, "entityId is not a GUID: " + form.getEntityId()); + } + } + + @Nullable + @Override + public Pair getAttachment(ListAttachmentForm form) + { + ListDefinitionImpl listDef = (ListDefinitionImpl)ListService.get().getList(getContainer(), form.getListId()); + if (listDef == null) + throw new NotFoundException("List does not exist in this container"); + + Container dataContainer = listDef.getListItemContainerForDownload(form.getEntityId(), getUser(), ReadPermission.class); + if (dataContainer == null) + throw new NotFoundException("List does not have an item for the entityid"); + + AttachmentParent parent = new ListItemAttachmentParent(form.getEntityId(), dataContainer); + + return new Pair<>(parent, form.getName()); + } + } + + + @RequiresPermission(DesignListPermission.class) + public static class ExportListArchiveAction extends ExportAction + { + @Override + public void export(ListDefinitionForm form, HttpServletResponse response, BindException errors) throws Exception + { + Container c = getContainer(); + List errorMessages = new ArrayList<>(); + Set selection = DataRegionSelection.getSelected(form.getViewContext(), false); + List> selectedLists = new LinkedList<>(); + Map duplicateNames = new HashMap<>(); + + for (Pair pair : getListIdContainerPairs(selection, c, errorMessages)) + { + String listName = Objects.requireNonNull(ListManager.get().getList(pair.second, pair.first)).getName(); + + //Display simple error to the user when Lists with the same names are selected. + if (duplicateNames.containsKey(listName)) + { + errors.reject(ERROR_MSG, "'" + listName + "' is already selected, please select Lists with unique names to Export."); + throw new ExportException(new SimpleErrorView(errors, true)); + } + else + { + duplicateNames.put(listName, pair.first); + } + // Issue 47289: Export List Archive if the user is an Admin of the folders of the selected Lists, else throw Permission error + if (!pair.second.hasPermission(getUser(), DesignListPermission.class)) + { + errors.reject(ERROR_MSG, String.format("List archive export is only supported for Lists in folders where you are an administrator. Try filtering to select only Lists in the local folder.")); + throw new ExportException(new SimpleErrorView(errors, true)); + } + selectedLists.add(pair); + } + + Set dataTypes = PageFlowUtil.set(FolderArchiveDataTypes.LIST_DESIGN, FolderArchiveDataTypes.LIST_DATA); + FolderExportContext ctx = new FolderExportContext(getUser(), c, dataTypes, "List Export", new StaticLoggerGetter(LogHelper.getLogger(ListController.class, "Export List Archive"))); + ctx.setLists(selectedLists); + ListWriter writer = new ListWriter(); + + // Export to a temporary file first so exceptions are displayed by the standard error page, Issue #44152 + // Same pattern as ExportFolderAction + Path tempDir = FileUtil.getTempDirectory().toPath(); + String filename = FileUtil.makeFileNameWithTimestamp(c.getName(), "lists.zip"); + + try (ZipFile zip = new ZipFile(tempDir, filename)) + { + writer.write(getUser(), zip, ctx); + } + + Path tempZipFile = tempDir.resolve(filename); + + // No exceptions, so stream the resulting zip file to the browser and delete it + try (OutputStream os = ZipFile.getOutputStream(getViewContext().getResponse(), filename)) + { + Files.copy(tempZipFile, os); + } + finally + { + Files.delete(tempZipFile); + } + } + } + + + @RequiresPermission(DesignListPermission.class) + public class ImportListArchiveAction extends FormViewAction + { + @Override + public void validateCommand(ListDefinitionForm target, Errors errors) + { + } + + @Override + public ModelAndView getView(ListDefinitionForm form, boolean reshow, BindException errors) + { + return new JspView<>("/org/labkey/list/view/importLists.jsp", null, errors); + } + + @Override + public boolean handlePost(ListDefinitionForm form, BindException errors) throws Exception + { + Map map = getFileMap(); + + if (map.isEmpty()) + { + errors.reject("listImport", "You must select a .list.zip file to import."); + } + else if (map.size() > 1) + { + errors.reject("listImport", "Only one file is allowed."); + } + else + { + MultipartFile file = map.values().iterator().next(); + + if (0 == file.getSize() || StringUtils.isBlank(file.getOriginalFilename())) + { + errors.reject("listImport", "You must select a .list.zip file to import."); + } + else + { + ListService.get().importListArchive(file.getInputStream(), errors, getContainer(), getUser()); + } + } + + return !errors.hasErrors(); + } + + @Override + public ActionURL getSuccessURL(ListDefinitionForm form) + { + return form.getReturnActionURL( getBeginURL(getContainer())); + } + + @Override + public void addNavTrail(NavTree root) + { + addRootNavTrail(root); + root.addChild("Import List Archive"); + } + } + + @RequiresPermission(ReadPermission.class) + public static class BrowseListsAction extends ReadOnlyApiAction + { + @Override + public ApiResponse execute(Object form, BindException errors) + { + ApiSimpleResponse response = new ApiSimpleResponse(); + + response.put("lists", getJSONLists(ListService.get().getLists(getContainer(), getUser(), true))); + response.put("success", true); + + return response; + } + + private List getJSONLists(Map lists){ + List listsJSON = new ArrayList<>(); + for(ListDefinition def : new TreeSet<>(lists.values())){ + JSONObject listObj = new JSONObject(); + listObj.put("name", def.getName()); + listObj.put("id", def.getListId()); + listObj.put("description", def.getDescription()); + listsJSON.add(listObj); + } + return listsJSON; + } + } + + @RequiresPermission(DesignListPermission.class) + public static class SetDefaultValuesListAction extends SetDefaultValuesAction + { + } + + /** + * Utility method to parse out Pair from a Collection where the strings are encoded + * pairs of listIds and container entityIds separated (e.g. "12,ff72c81e-ce2d-103a-b3ce-e8f660509016"). + */ + private static List> getListIdContainerPairs( + Collection listIdContainers, + Container currentContainer, + Collection errors) + { + List> pairs = new ArrayList<>(); + + for (String s : listIdContainers) + { + String[] parts = s.split(","); + Container c; + if (parts.length > 1) + c = ContainerManager.getForId(parts[1]); + else + c = currentContainer; + if (c == null) + { + errors.add(String.format("Container not found for %s", s)); + continue; + } + + try + { + int listId = Integer.parseInt(parts[0]); + pairs.add(Pair.of(listId, c)); + } + catch (NumberFormatException badListId) + { + errors.add(String.format("Invalid listId: %s", s)); + } + } + + return pairs; + } +} diff --git a/list/src/org/labkey/list/model/ListDefinitionImpl.java b/list/src/org/labkey/list/model/ListDefinitionImpl.java index 0eb1d40090e..72ec6dbcc66 100644 --- a/list/src/org/labkey/list/model/ListDefinitionImpl.java +++ b/list/src/org/labkey/list/model/ListDefinitionImpl.java @@ -1,852 +1,852 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.LookupResolutionType; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.ImportProgress; -import org.labkey.api.exp.DomainNotFoundException; -import org.labkey.api.exp.Lsid; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.TemplateInfo; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.DomainUtil; -import org.labkey.api.exp.property.PropertyService; -import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QueryAction; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryUpdateService; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.query.UserSchema; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.reader.MapLoader; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.Permission; -import org.labkey.api.util.ReentrantLockWithName; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.writer.VirtualFile; -import org.labkey.list.controllers.ListController; -import org.springframework.web.servlet.mvc.Controller; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; - -import static org.labkey.api.util.GUID.makeGUID; - -public class ListDefinitionImpl implements ListDefinition -{ - private static final Logger LOG = LogManager.getLogger(ListDefinitionImpl.class); - - @Nullable - static public ListDefinitionImpl of(@Nullable ListDef def) - { - if (def == null) - return null; - return new ListDefinitionImpl(def); - } - - private boolean _new; - // If set to a collection of IDs, we'll attempt to use them (in succession) as the list ID on insert - private Collection _preferredListIds = Collections.emptyList(); - private Domain _domain; - - ListDef.ListDefBuilder _def; - - - public ListDefinitionImpl(ListDef def) - { - _def = new ListDef.ListDefBuilder(def); - } - - - public ListDefinitionImpl(Container container, String name, KeyType keyType, @Nullable Category category, TemplateInfo templateInfo) - { - _new = true; - ListDef.ListDefBuilder builder = new ListDef.ListDefBuilder(); - builder.setContainer(container.getId()); - builder.setName(name); - builder.setEntityId(makeGUID()); - builder.setKeyType(keyType.toString()); - builder.setCategory(category); - _def = builder; - Lsid lsid = ListDomainKind.generateDomainURI(container, keyType, category); - _domain = PropertyService.get().createDomain(container, lsid.toString(), name, templateInfo); - } - - // For new lists only, we'll attempt to use these IDs at insert time - @Override - public void setPreferredListIds(Collection preferredListIds) - { - _preferredListIds = preferredListIds; - } - - @Override - public int getListId() - { - return _def.getListId(); - } - - public String getEntityId() - { - return _def.getEntityId(); - } - - @Override - public Container getContainer() - { - return ContainerManager.getForId(_def.getContainerId()); - } - - @Override - @Nullable - public Domain getDomain() - { - return getDomain(false); - } - - @Override - @Nullable - public Domain getDomain(boolean forUpdate) - { - if (_domain == null || (forUpdate && !_domain.isMutable())) // assure we have a mutable domain if needed, but don't ditch a mutable one because it may not have been saved yet - { - _domain = PropertyService.get().getDomain(_def.getDomainId(), forUpdate); - } - return _domain; - } - - @Override - public @NotNull Domain getDomainOrThrow() - { - return getDomainOrThrow(false); - } - - @Override - public @NotNull Domain getDomainOrThrow(boolean forUpdate) - { - var domain = getDomain(forUpdate); - if (domain == null) - throw new IllegalArgumentException("Could not find domain for list \"" + getName() + "\"."); - return domain; - } - - @Override - public String getName() - { - return _def.getName(); - } - - @Override - public String getKeyName() - { - return _def.getKeyName(); - } - - @Override - public void setKeyName(String name) - { - if (_def.getTitleColumn() != null && _def.getTitleColumn().equals(getKeyName())) - { - edit().setTitleColumn(name); - } - edit().setKeyName(name); - } - - @Override - public void setDescription(String description) - { - edit().setDescription(description); - } - - @Override - public KeyType getKeyType() - { - return KeyType.valueOf(_def.getKeyType()); - } - - @Override - public void setKeyType(KeyType type) - { - edit().setKeyType(type.toString()); - } - - @Override - public Category getCategory() - { - return _def.getCategory(); - } - - @Override - public void setCategory(Category category) - { - edit().setCategory(category); - } - - @Override - public int getCreatedBy() { return _def.getCreatedBy(); } - - @Override - public boolean getAllowDelete() - { - return _def.getAllowDelete(); - } - - @Override - public void setAllowDelete(boolean allowDelete) - { - edit().setAllowDelete(allowDelete); - } - - @Override - public boolean getAllowUpload() - { - return _def.getAllowUpload(); - } - - @Override - public void setAllowUpload(boolean allowUpload) - { - edit().setAllowUpload(allowUpload); - } - - @Override - public boolean getAllowExport() - { - return _def.getAllowExport(); - } - - @Override - public void setAllowExport(boolean allowExport) - { - edit().setAllowExport(allowExport); - } - - @Override - public boolean getEntireListIndex() - { - return _def.isEntireListIndex(); - } - - @Override - public void setEntireListIndex(boolean eachItemIndex) - { - edit().setEntireListIndex(eachItemIndex); - } - - @Override - public IndexSetting getEntireListIndexSetting() - { - return _def.getEntireListIndexSettingEnum(); - } - - @Override - public void setEntireListIndexSetting(IndexSetting setting) - { - edit().setEntireListIndexSettingEnum(setting); - } - - @Override - public @Nullable String getEntireListTitleTemplate() - { - return _def.getEntireListTitleTemplate(); - } - - @Override - public void setEntireListTitleTemplate(@Nullable String template) - { - edit().setEntireListTitleTemplate(template); - } - - @Override - public BodySetting getEntireListBodySetting() - { - return _def.getEntireListBodySettingEnum(); - } - - @Override - public void setEntireListBodySetting(BodySetting setting) - { - edit().setEntireListBodySettingEnum(setting); - } - - @Override - public String getEntireListBodyTemplate() - { - return _def.getEntireListBodyTemplate(); - } - - @Override - public void setEntireListBodyTemplate(String template) - { - edit().setEntireListBodyTemplate(template); - } - - @Override - public boolean getEachItemIndex() - { - return _def.isEachItemIndex(); - } - - @Override - public void setEachItemIndex(boolean eachItemIndex) - { - edit().setEachItemIndex(eachItemIndex); - } - - @Override - public @Nullable String getEachItemTitleTemplate() - { - return _def.getEachItemTitleTemplate(); - } - - @Override - public void setEachItemTitleTemplate(@Nullable String template) - { - edit().setEachItemTitleTemplate(template); - } - - @Override - public BodySetting getEachItemBodySetting() - { - return _def.getEachItemBodySettingEnum(); - } - - @Override - public void setEachItemBodySetting(BodySetting setting) - { - edit().setEachItemBodySettingEnum(setting); - } - - @Override - public String getEachItemBodyTemplate() - { - return _def.getEachItemBodyTemplate(); - } - - @Override - public void setEachItemBodyTemplate(String template) - { - edit().setEachItemBodyTemplate(template); - } - - @Override - public boolean getFileAttachmentIndex() - { - return _def.isFileAttachmentIndex(); - } - - @Override - public void setFileAttachmentIndex(boolean fileAttachmentIndex) - { - edit().setFileAttachmentIndex(fileAttachmentIndex); - } - - - @Override - public void save(User user) throws Exception - { - save(user, true, null, null); - } - - private static final ReentrantLock _saveLock = new ReentrantLockWithName(ListDefinitionImpl.class, "_saveLock"); - - @Override - public void save(User user, boolean ensureKey, @Nullable Map newRecordMap, @Nullable List calculatedFields) throws Exception - { - if (ensureKey) - { - assert getKeyName() != null : "Key not provided for List: " + getName(); - assert getKeyType() != null : "Invalid Key Type for List: " + getName(); - } - - try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction(_saveLock)) - { - if (ensureKey) - ensureKey(); - - Domain domain = getDomain(true); - - if (_new) - { - // The domain kind cannot lookup the list definition if the domain has not been saved - ((ListDomainKind) domain.getDomainKind()).setListDefinition(this); - - domain.save(user, newRecordMap, calculatedFields); - - _def.setDomainId(domain.getTypeId()); - ListDef inserted = ListManager.get().insert(user, _def, _preferredListIds); - _def = new ListDef.ListDefBuilder(inserted); - _new = false; - - ListManager.get().addAuditEvent(this, user, String.format("The list %s was created", _def.getName())); - } - else - { - ListDef updated = ListManager.get().update(user, _def); - _def = new ListDef.ListDefBuilder(updated); - ListManager.get().addAuditEvent(this, user, String.format("The definition of the list %s was modified", _def.getName())); - } - - transaction.commit(); - } - catch (RuntimeSQLException e) - { - if (RuntimeSQLException.isConstraintException(e.getSQLException())) - throw new ValidationException("The name '" + _def.getName() + "' is already in use."); - throw e; - } - - // Fetch the domain again to prime the cache, reducing potential for DB deadlocks - _domain = null; - getDomain(); - - ListManager.get().indexList(_def); - } - - private void ensureKey() - { - Domain domain = getDomain(); - for (DomainProperty dp : domain.getProperties()) - { - if (dp.getName().equalsIgnoreCase(getKeyName())) - return; - } - - DomainProperty prop = domain.addProperty(); - prop.setPropertyURI(DomainUtil.createUniquePropertyURI(domain.getTypeURI())); - prop.setName(getKeyName()); - prop.setType(PropertyService.get().getType(domain.getContainer(), getKeyType().getPropertyType().getXmlName())); - - domain.setPropertyIndex(prop, 0); - } - - @Override - public ListItem createListItem() - { - return new ListItemImpl(this); - } - - @Override - public ListItem getListItem(Object key, User user) - { - return getListItem(key, user, getContainer()); - } - - @Override - public ListItem getListItem(Object key, User user, Container c) - { - // Convert key value to the proper type, since PostgreSQL 8.3 requires that key parameter types match their column types. - Object typedKey = getKeyType().convertKey(key); - - return getListItem(new SimpleFilter(FieldKey.fromParts(getKeyName()), typedKey), user, c); - } - - @Override - public ListItem getListItemForEntityId(String entityId, User user) - { - return getListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer()); - } - - private ListItem getListItem(SimpleFilter filter, User user, Container c) - { - TableInfo tbl = new ListQuerySchema(user, c).getTable(getName()); - - if (null == tbl) - return null; - - Map row = null; - - try - { - row = new TableSelector(tbl, filter, null).getMap(); - } - catch (IllegalStateException e) - { - /* more than one row matches */ - } - - if (row == null) - return null; - - ListItm itm = new ListItm(); - - itm.setListId(getListId()); - itm.setEntityId(row.get("EntityId").toString()); - itm.setKey(row.get(getKeyName())); - - ListItemImpl impl = new ListItemImpl(this, itm); - for (DomainProperty prop : getDomainOrThrow().getProperties()) - { - impl.setProperty(prop, row.get(prop.getName())); - } - - return impl; - } - - public Container getListItemContainerForDownload(String entityId, User user, Class permissionClass) - { - Container c = getContainer(); - SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("EntityId"), entityId); - // Use a relax CF to find the list items, permission will be validated later - ContainerFilter cf = ContainerFilter.Type.AllInProjectPlusShared.create(c, user); - TableInfo tbl = getTable(user, c, cf); - - if (null == tbl) - return null; - - Map row = null; - - try - { - row = new TableSelector(tbl, filter, null).getMap(); - } - catch (IllegalStateException e) - { - /* more than one row matches */ - } - - if (row == null) - return null; - - Container dataContainer = row.get("Container") != null ? ContainerManager.getForId(row.get("Container").toString()) : null; - if (dataContainer != null && dataContainer.hasPermission(user, permissionClass)) - return dataContainer; - - return null; - } - - @Override - public void delete(User user) throws DomainNotFoundException - { - delete(user, null); - } - - @Override - public void delete(User user, @Nullable String auditUserComment) throws DomainNotFoundException - { - TableInfo table = getTable(user); - QueryUpdateService qus = null; - - if (null != table) - qus = table.getUpdateService(); - - // In certain cases we may create a list that is not viable (i.e., one in which a table was never created because - // the metadata wasn't valid). Still allow deleting the list - try (DbScope.Transaction transaction = (table != null) ? table.getSchema().getScope().ensureTransaction() : - ExperimentService.get().ensureTransaction()) - { - // remove related attachments, discussions, and indices - ListManager.get().deleteIndexedList(this); - if (qus instanceof ListQueryUpdateService listQus) - listQus.deleteRelatedListData(user, getContainer()); - - // then delete the list itself - ListManager.get().deleteListDef(getContainer(), getListId()); - Domain domain = getDomainOrThrow(); - domain.delete(user, auditUserComment); - - ListManager.get().addAuditEvent(this, user, String.format("The list %s was deleted", _def.getName())); - - transaction.commit(); - } - - SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); - QueryService.get().fireQueryDeleted(user, getContainer(), null, schemaPath, Collections.singleton(getName())); - } - - - @Override - public int insertListItems(User user, Container container, List listItems) - { - BatchValidationException ve = new BatchValidationException(); - - List> rows = new ArrayList<>(); - - for (ListItem item : listItems) - { - Map row = new CaseInsensitiveHashMap<>(); - Map propertyMap = item.getProperties(); - - if (null != propertyMap) - { - for (String key : propertyMap.keySet()) - { - ObjectProperty prop = propertyMap.get(key); - if (null != prop) - { - row.put(prop.getName(), prop.getObjectValue()); - } - } - rows.add(row); - } - } - - MapLoader loader = new MapLoader(rows); - - // TODO: Find out the attachment directory? - return insertListItems(user, container, loader, ve, null, null, false, LookupResolutionType.primaryKey); - } - - - @Override - public int insertListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType) - { - return importListItems(user, container, loader, errors, attachmentDir, progress, supportAutoIncrementKey, lookupResolutionType, QueryUpdateService.InsertOption.INSERT); - } - - @Override - public int importListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType, QueryUpdateService.InsertOption insertOption) - { - ListQuerySchema schema = new ListQuerySchema(user, container); - TableInfo table = schema.getTable(_def.getName()); - if (null != table) - { - ListQueryUpdateService lqus = (ListQueryUpdateService) table.getUpdateService(); - if (null != lqus) - return lqus.insertUsingDataIterator(loader, user, container, errors, attachmentDir, progress, supportAutoIncrementKey, insertOption, lookupResolutionType); - } - return 0; - } - - - @Override - public String getDescription() - { - return _def.getDescription(); - } - - @Override - public String getTitleColumn() - { - return _def.getTitleColumn(); - } - - @Override - public void setTitleColumn(String titleColumn) - { - edit().setTitleColumn(titleColumn); - } - - @Override - public Date getModified() - { - return _def.getModified(); - } - - @Override - public void setModified(Date modified) - { - edit().setModified(modified); - } - - @Override - public Date getLastIndexed() - { - return _def.getLastIndexed(); - } - - @Override - public void setLastIndexed(Date modified) - { - edit().setLastIndexed(modified); - } - - @Override - @Nullable - public TableInfo getTable(User user) - { - return getTable(user, getContainer()); - } - - @Override - @Nullable - public TableInfo getTable(User user, Container c) - { - return getTable(user, c, null); - } - - @Override - @Nullable - public TableInfo getTable(User user, Container c, @Nullable ContainerFilter cf) - { - TableInfo table; - try - { - if (null != getDomain()) - { - // Go through the schema so we always get all the XML metadata applied - UserSchema schema = new ListQuerySchema(user, c); - table = schema.getTable(getName(), cf, true, false); - } - else - { - table = null; - LOG.warn("Failed to construct list table because domain not found"); - } - } - catch (IllegalStateException e) - { - /* Return a null table -- configuration failed */ - table = null; - LOG.warn("Failed to construct list table", e); - } - - return table; - } - - @Override - public TableInfo getTableForInsert(User user, Container c) - { - return getTable(user, c, QueryService.get().getContainerFilterForLookups(c, user)); - } - - @Override - public ActionURL urlImport(Container c) - { - return urlForName(ListController.UploadListItemsAction.class, c); - } - - @Override - public ActionURL urlShowDefinition() - { - return urlFor(ListController.EditListDefinitionAction.class, getContainer()); - } - - @Override - public ActionURL urlShowData(Container c) - { - return urlForName(ListController.GridAction.class, c); - } - - @Override - public ActionURL urlUpdate(User user, Container container, @Nullable Object pk, @Nullable URLHelper returnAndCancelUrl) - { - ActionURL url = QueryService.get().urlFor(user, container, QueryAction.updateQueryRow, ListQuerySchema.NAME, getName()); - - // Can be null if caller will be filling in pk (e.g., grid edit column) - if (null != pk) - url.addParameter("pk", pk.toString()); - - if (returnAndCancelUrl != null) - { - url.addCancelURL(returnAndCancelUrl); - url.addReturnUrl(returnAndCancelUrl); - } - - return url; - } - - @Override - public ActionURL urlDetails(@Nullable Object pk) - { - return urlDetails(pk, getContainer()); - } - - @Override - public ActionURL urlDetails(@Nullable Object pk, Container c) - { - ActionURL url = urlForName(ListController.DetailsAction.class, c); - // Can be null if caller will be filling in pk (e.g., grid edit column) - - if (null != pk) - url.addParameter("pk", pk.toString()); - - return url; - } - - @Override - public ActionURL urlShowHistory(Container c) - { - return urlFor(ListController.HistoryAction.class, c); - } - - @Override - public ActionURL urlShowData() - { - return urlShowData(getContainer()); - } - - @Override - public ActionURL urlFor(Class actionClass) - { - return urlFor(actionClass, getContainer()); - } - - @Override - public ActionURL urlFor(Class actionClass, Container c) - { - ActionURL ret = new ActionURL(actionClass, c); - ret.addParameter("listId", getListId()); - return ret; - } - - private ActionURL urlForName(Class actionClass, Container c) - { - ActionURL ret = new ActionURL(actionClass, c); - ret.addParameter("name", getName()); - return ret; - } - - private ListDef.ListDefBuilder edit() - { - return _def; - } - - @Override - public Collection getDependents(User user) - { - SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); - return QueryService.get().getQueryDependents(user, getContainer(), null, schemaPath, Collections.singleton(getName())); - } - - @Override - public String toString() - { - return getName() + ", id: " + getListId(); - } - - @Override - public int compareTo(ListDefinition l) - { - return getName().compareToIgnoreCase(l.getName()); - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.LookupResolutionType; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.ImportProgress; +import org.labkey.api.exp.DomainNotFoundException; +import org.labkey.api.exp.Lsid; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.TemplateInfo; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.DomainUtil; +import org.labkey.api.exp.property.PropertyService; +import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QueryAction; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryUpdateService; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.query.UserSchema; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.reader.MapLoader; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.Permission; +import org.labkey.api.util.ReentrantLockWithName; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.writer.VirtualFile; +import org.labkey.list.controllers.ListController; +import org.springframework.web.servlet.mvc.Controller; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantLock; + +import static org.labkey.api.util.GUID.makeGUID; + +public class ListDefinitionImpl implements ListDefinition +{ + private static final Logger LOG = LogManager.getLogger(ListDefinitionImpl.class); + + @Nullable + static public ListDefinitionImpl of(@Nullable ListDef def) + { + if (def == null) + return null; + return new ListDefinitionImpl(def); + } + + private boolean _new; + // If set to a collection of IDs, we'll attempt to use them (in succession) as the list ID on insert + private Collection _preferredListIds = Collections.emptyList(); + private Domain _domain; + + ListDef.ListDefBuilder _def; + + + public ListDefinitionImpl(ListDef def) + { + _def = new ListDef.ListDefBuilder(def); + } + + + public ListDefinitionImpl(Container container, String name, KeyType keyType, @Nullable Category category, TemplateInfo templateInfo) + { + _new = true; + ListDef.ListDefBuilder builder = new ListDef.ListDefBuilder(); + builder.setContainer(container.getId()); + builder.setName(name); + builder.setEntityId(makeGUID()); + builder.setKeyType(keyType.toString()); + builder.setCategory(category); + _def = builder; + Lsid lsid = ListDomainKind.generateDomainURI(container, keyType, category); + _domain = PropertyService.get().createDomain(container, lsid.toString(), name, templateInfo); + } + + // For new lists only, we'll attempt to use these IDs at insert time + @Override + public void setPreferredListIds(Collection preferredListIds) + { + _preferredListIds = preferredListIds; + } + + @Override + public int getListId() + { + return _def.getListId(); + } + + public String getEntityId() + { + return _def.getEntityId(); + } + + @Override + public Container getContainer() + { + return ContainerManager.getForId(_def.getContainerId()); + } + + @Override + @Nullable + public Domain getDomain() + { + return getDomain(false); + } + + @Override + @Nullable + public Domain getDomain(boolean forUpdate) + { + if (_domain == null || (forUpdate && !_domain.isMutable())) // assure we have a mutable domain if needed, but don't ditch a mutable one because it may not have been saved yet + { + _domain = PropertyService.get().getDomain(_def.getDomainId(), forUpdate); + } + return _domain; + } + + @Override + public @NotNull Domain getDomainOrThrow() + { + return getDomainOrThrow(false); + } + + @Override + public @NotNull Domain getDomainOrThrow(boolean forUpdate) + { + var domain = getDomain(forUpdate); + if (domain == null) + throw new IllegalArgumentException("Could not find domain for list \"" + getName() + "\"."); + return domain; + } + + @Override + public String getName() + { + return _def.getName(); + } + + @Override + public String getKeyName() + { + return _def.getKeyName(); + } + + @Override + public void setKeyName(String name) + { + if (_def.getTitleColumn() != null && _def.getTitleColumn().equals(getKeyName())) + { + edit().setTitleColumn(name); + } + edit().setKeyName(name); + } + + @Override + public void setDescription(String description) + { + edit().setDescription(description); + } + + @Override + public KeyType getKeyType() + { + return KeyType.valueOf(_def.getKeyType()); + } + + @Override + public void setKeyType(KeyType type) + { + edit().setKeyType(type.toString()); + } + + @Override + public Category getCategory() + { + return _def.getCategory(); + } + + @Override + public void setCategory(Category category) + { + edit().setCategory(category); + } + + @Override + public int getCreatedBy() { return _def.getCreatedBy(); } + + @Override + public boolean getAllowDelete() + { + return _def.getAllowDelete(); + } + + @Override + public void setAllowDelete(boolean allowDelete) + { + edit().setAllowDelete(allowDelete); + } + + @Override + public boolean getAllowUpload() + { + return _def.getAllowUpload(); + } + + @Override + public void setAllowUpload(boolean allowUpload) + { + edit().setAllowUpload(allowUpload); + } + + @Override + public boolean getAllowExport() + { + return _def.getAllowExport(); + } + + @Override + public void setAllowExport(boolean allowExport) + { + edit().setAllowExport(allowExport); + } + + @Override + public boolean getEntireListIndex() + { + return _def.isEntireListIndex(); + } + + @Override + public void setEntireListIndex(boolean eachItemIndex) + { + edit().setEntireListIndex(eachItemIndex); + } + + @Override + public IndexSetting getEntireListIndexSetting() + { + return _def.getEntireListIndexSettingEnum(); + } + + @Override + public void setEntireListIndexSetting(IndexSetting setting) + { + edit().setEntireListIndexSettingEnum(setting); + } + + @Override + public @Nullable String getEntireListTitleTemplate() + { + return _def.getEntireListTitleTemplate(); + } + + @Override + public void setEntireListTitleTemplate(@Nullable String template) + { + edit().setEntireListTitleTemplate(template); + } + + @Override + public BodySetting getEntireListBodySetting() + { + return _def.getEntireListBodySettingEnum(); + } + + @Override + public void setEntireListBodySetting(BodySetting setting) + { + edit().setEntireListBodySettingEnum(setting); + } + + @Override + public String getEntireListBodyTemplate() + { + return _def.getEntireListBodyTemplate(); + } + + @Override + public void setEntireListBodyTemplate(String template) + { + edit().setEntireListBodyTemplate(template); + } + + @Override + public boolean getEachItemIndex() + { + return _def.isEachItemIndex(); + } + + @Override + public void setEachItemIndex(boolean eachItemIndex) + { + edit().setEachItemIndex(eachItemIndex); + } + + @Override + public @Nullable String getEachItemTitleTemplate() + { + return _def.getEachItemTitleTemplate(); + } + + @Override + public void setEachItemTitleTemplate(@Nullable String template) + { + edit().setEachItemTitleTemplate(template); + } + + @Override + public BodySetting getEachItemBodySetting() + { + return _def.getEachItemBodySettingEnum(); + } + + @Override + public void setEachItemBodySetting(BodySetting setting) + { + edit().setEachItemBodySettingEnum(setting); + } + + @Override + public String getEachItemBodyTemplate() + { + return _def.getEachItemBodyTemplate(); + } + + @Override + public void setEachItemBodyTemplate(String template) + { + edit().setEachItemBodyTemplate(template); + } + + @Override + public boolean getFileAttachmentIndex() + { + return _def.isFileAttachmentIndex(); + } + + @Override + public void setFileAttachmentIndex(boolean fileAttachmentIndex) + { + edit().setFileAttachmentIndex(fileAttachmentIndex); + } + + + @Override + public void save(User user) throws Exception + { + save(user, true, null, null); + } + + private static final ReentrantLock _saveLock = new ReentrantLockWithName(ListDefinitionImpl.class, "_saveLock"); + + @Override + public void save(User user, boolean ensureKey, @Nullable Map newRecordMap, @Nullable List calculatedFields) throws Exception + { + if (ensureKey) + { + assert getKeyName() != null : "Key not provided for List: " + getName(); + assert getKeyType() != null : "Invalid Key Type for List: " + getName(); + } + + try (DbScope.Transaction transaction = ExperimentService.get().getSchema().getScope().ensureTransaction(_saveLock)) + { + if (ensureKey) + ensureKey(); + + Domain domain = getDomain(true); + + if (_new) + { + // The domain kind cannot lookup the list definition if the domain has not been saved + ((ListDomainKind) domain.getDomainKind()).setListDefinition(this); + + domain.save(user, newRecordMap, calculatedFields); + + _def.setDomainId(domain.getTypeId()); + ListDef inserted = ListManager.get().insert(user, _def, _preferredListIds); + _def = new ListDef.ListDefBuilder(inserted); + _new = false; + + ListManager.get().addAuditEvent(this, user, String.format("The list %s was created", _def.getName())); + } + else + { + ListDef updated = ListManager.get().update(user, _def); + _def = new ListDef.ListDefBuilder(updated); + ListManager.get().addAuditEvent(this, user, String.format("The definition of the list %s was modified", _def.getName())); + } + + transaction.commit(); + } + catch (RuntimeSQLException e) + { + if (RuntimeSQLException.isConstraintException(e.getSQLException())) + throw new ValidationException("The name '" + _def.getName() + "' is already in use."); + throw e; + } + + // Fetch the domain again to prime the cache, reducing potential for DB deadlocks + _domain = null; + getDomain(); + + ListManager.get().indexList(_def); + } + + private void ensureKey() + { + Domain domain = getDomain(); + for (DomainProperty dp : domain.getProperties()) + { + if (dp.getName().equalsIgnoreCase(getKeyName())) + return; + } + + DomainProperty prop = domain.addProperty(); + prop.setPropertyURI(DomainUtil.createUniquePropertyURI(domain.getTypeURI())); + prop.setName(getKeyName()); + prop.setType(PropertyService.get().getType(domain.getContainer(), getKeyType().getPropertyType().getXmlName())); + + domain.setPropertyIndex(prop, 0); + } + + @Override + public ListItem createListItem() + { + return new ListItemImpl(this); + } + + @Override + public ListItem getListItem(Object key, User user) + { + return getListItem(key, user, getContainer()); + } + + @Override + public ListItem getListItem(Object key, User user, Container c) + { + // Convert key value to the proper type, since PostgreSQL 8.3 requires that key parameter types match their column types. + Object typedKey = getKeyType().convertKey(key); + + return getListItem(new SimpleFilter(FieldKey.fromParts(getKeyName()), typedKey), user, c); + } + + @Override + public ListItem getListItemForEntityId(String entityId, User user) + { + return getListItem(new SimpleFilter(FieldKey.fromParts("EntityId"), entityId), user, getContainer()); + } + + private ListItem getListItem(SimpleFilter filter, User user, Container c) + { + TableInfo tbl = new ListQuerySchema(user, c).getTable(getName()); + + if (null == tbl) + return null; + + Map row = null; + + try + { + row = new TableSelector(tbl, filter, null).getMap(); + } + catch (IllegalStateException e) + { + /* more than one row matches */ + } + + if (row == null) + return null; + + ListItm itm = new ListItm(); + + itm.setListId(getListId()); + itm.setEntityId(row.get("EntityId").toString()); + itm.setKey(row.get(getKeyName())); + + ListItemImpl impl = new ListItemImpl(this, itm); + for (DomainProperty prop : getDomainOrThrow().getProperties()) + { + impl.setProperty(prop, row.get(prop.getName())); + } + + return impl; + } + + public Container getListItemContainerForDownload(String entityId, User user, Class permissionClass) + { + Container c = getContainer(); + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("EntityId"), entityId); + // Use a relax CF to find the list items, permission will be validated later + ContainerFilter cf = ContainerFilter.Type.AllInProjectPlusShared.create(c, user); + TableInfo tbl = getTable(user, c, cf); + + if (null == tbl) + return null; + + Map row = null; + + try + { + row = new TableSelector(tbl, filter, null).getMap(); + } + catch (IllegalStateException e) + { + /* more than one row matches */ + } + + if (row == null) + return null; + + Container dataContainer = row.get("Container") != null ? ContainerManager.getForId(row.get("Container").toString()) : null; + if (dataContainer != null && dataContainer.hasPermission(user, permissionClass)) + return dataContainer; + + return null; + } + + @Override + public void delete(User user) throws DomainNotFoundException + { + delete(user, null); + } + + @Override + public void delete(User user, @Nullable String auditUserComment) throws DomainNotFoundException + { + TableInfo table = getTable(user); + QueryUpdateService qus = null; + + if (null != table) + qus = table.getUpdateService(); + + // In certain cases we may create a list that is not viable (i.e., one in which a table was never created because + // the metadata wasn't valid). Still allow deleting the list + try (DbScope.Transaction transaction = (table != null) ? table.getSchema().getScope().ensureTransaction() : + ExperimentService.get().ensureTransaction()) + { + // remove related attachments, discussions, and indices + ListManager.get().deleteIndexedList(this); + if (qus instanceof ListQueryUpdateService listQus) + listQus.deleteRelatedListData(user, getContainer()); + + // then delete the list itself + ListManager.get().deleteListDef(getContainer(), getListId()); + Domain domain = getDomainOrThrow(); + domain.delete(user, auditUserComment); + + ListManager.get().addAuditEvent(this, user, String.format("The list %s was deleted", _def.getName())); + + transaction.commit(); + } + + SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); + QueryService.get().fireQueryDeleted(user, getContainer(), null, schemaPath, Collections.singleton(getName())); + } + + + @Override + public int insertListItems(User user, Container container, List listItems) + { + BatchValidationException ve = new BatchValidationException(); + + List> rows = new ArrayList<>(); + + for (ListItem item : listItems) + { + Map row = new CaseInsensitiveHashMap<>(); + Map propertyMap = item.getProperties(); + + if (null != propertyMap) + { + for (String key : propertyMap.keySet()) + { + ObjectProperty prop = propertyMap.get(key); + if (null != prop) + { + row.put(prop.getName(), prop.getObjectValue()); + } + } + rows.add(row); + } + } + + MapLoader loader = new MapLoader(rows); + + // TODO: Find out the attachment directory? + return insertListItems(user, container, loader, ve, null, null, false, LookupResolutionType.primaryKey); + } + + + @Override + public int insertListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType) + { + return importListItems(user, container, loader, errors, attachmentDir, progress, supportAutoIncrementKey, lookupResolutionType, QueryUpdateService.InsertOption.INSERT); + } + + @Override + public int importListItems(User user, Container container, DataLoader loader, @NotNull BatchValidationException errors, @Nullable VirtualFile attachmentDir, @Nullable ImportProgress progress, boolean supportAutoIncrementKey, LookupResolutionType lookupResolutionType, QueryUpdateService.InsertOption insertOption) + { + ListQuerySchema schema = new ListQuerySchema(user, container); + TableInfo table = schema.getTable(_def.getName()); + if (null != table) + { + ListQueryUpdateService lqus = (ListQueryUpdateService) table.getUpdateService(); + if (null != lqus) + return lqus.insertUsingDataIterator(loader, user, container, errors, attachmentDir, progress, supportAutoIncrementKey, insertOption, lookupResolutionType); + } + return 0; + } + + + @Override + public String getDescription() + { + return _def.getDescription(); + } + + @Override + public String getTitleColumn() + { + return _def.getTitleColumn(); + } + + @Override + public void setTitleColumn(String titleColumn) + { + edit().setTitleColumn(titleColumn); + } + + @Override + public Date getModified() + { + return _def.getModified(); + } + + @Override + public void setModified(Date modified) + { + edit().setModified(modified); + } + + @Override + public Date getLastIndexed() + { + return _def.getLastIndexed(); + } + + @Override + public void setLastIndexed(Date modified) + { + edit().setLastIndexed(modified); + } + + @Override + @Nullable + public TableInfo getTable(User user) + { + return getTable(user, getContainer()); + } + + @Override + @Nullable + public TableInfo getTable(User user, Container c) + { + return getTable(user, c, null); + } + + @Override + @Nullable + public TableInfo getTable(User user, Container c, @Nullable ContainerFilter cf) + { + TableInfo table; + try + { + if (null != getDomain()) + { + // Go through the schema so we always get all the XML metadata applied + UserSchema schema = new ListQuerySchema(user, c); + table = schema.getTable(getName(), cf, true, false); + } + else + { + table = null; + LOG.warn("Failed to construct list table because domain not found"); + } + } + catch (IllegalStateException e) + { + /* Return a null table -- configuration failed */ + table = null; + LOG.warn("Failed to construct list table", e); + } + + return table; + } + + @Override + public TableInfo getTableForInsert(User user, Container c) + { + return getTable(user, c, QueryService.get().getContainerFilterForLookups(c, user)); + } + + @Override + public ActionURL urlImport(Container c) + { + return urlForName(ListController.UploadListItemsAction.class, c); + } + + @Override + public ActionURL urlShowDefinition() + { + return urlFor(ListController.EditListDefinitionAction.class, getContainer()); + } + + @Override + public ActionURL urlShowData(Container c) + { + return urlForName(ListController.GridAction.class, c); + } + + @Override + public ActionURL urlUpdate(User user, Container container, @Nullable Object pk, @Nullable URLHelper returnAndCancelUrl) + { + ActionURL url = QueryService.get().urlFor(user, container, QueryAction.updateQueryRow, ListQuerySchema.NAME, getName()); + + // Can be null if caller will be filling in pk (e.g., grid edit column) + if (null != pk) + url.addParameter("pk", pk.toString()); + + if (returnAndCancelUrl != null) + { + url.addCancelURL(returnAndCancelUrl); + url.addReturnUrl(returnAndCancelUrl); + } + + return url; + } + + @Override + public ActionURL urlDetails(@Nullable Object pk) + { + return urlDetails(pk, getContainer()); + } + + @Override + public ActionURL urlDetails(@Nullable Object pk, Container c) + { + ActionURL url = urlForName(ListController.DetailsAction.class, c); + // Can be null if caller will be filling in pk (e.g., grid edit column) + + if (null != pk) + url.addParameter("pk", pk.toString()); + + return url; + } + + @Override + public ActionURL urlShowHistory(Container c) + { + return urlFor(ListController.HistoryAction.class, c); + } + + @Override + public ActionURL urlShowData() + { + return urlShowData(getContainer()); + } + + @Override + public ActionURL urlFor(Class actionClass) + { + return urlFor(actionClass, getContainer()); + } + + @Override + public ActionURL urlFor(Class actionClass, Container c) + { + ActionURL ret = new ActionURL(actionClass, c); + ret.addParameter("listId", getListId()); + return ret; + } + + private ActionURL urlForName(Class actionClass, Container c) + { + ActionURL ret = new ActionURL(actionClass, c); + ret.addParameter("name", getName()); + return ret; + } + + private ListDef.ListDefBuilder edit() + { + return _def; + } + + @Override + public Collection getDependents(User user) + { + SchemaKey schemaPath = SchemaKey.fromParts(ListQuerySchema.NAME); + return QueryService.get().getQueryDependents(user, getContainer(), null, schemaPath, Collections.singleton(getName())); + } + + @Override + public String toString() + { + return getName() + ", id: " + getListId(); + } + + @Override + public int compareTo(ListDefinition l) + { + return getName().compareToIgnoreCase(l.getName()); + } +} diff --git a/list/src/org/labkey/list/model/ListManagerSchema.java b/list/src/org/labkey/list/model/ListManagerSchema.java index 55628fb0254..7b1854c022c 100644 --- a/list/src/org/labkey/list/model/ListManagerSchema.java +++ b/list/src/org/labkey/list/model/ListManagerSchema.java @@ -1,263 +1,263 @@ -/* - * Copyright (c) 2014-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.Container; -import org.labkey.api.data.DataRegion; -import org.labkey.api.data.MenuButton; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.SimpleDisplayColumn; -import org.labkey.api.data.Sort; -import org.labkey.api.data.TableInfo; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.module.Module; -import org.labkey.api.query.DefaultSchema; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.QuerySchema; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryView; -import org.labkey.api.query.UserSchema; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.AdminPermission; -import org.labkey.api.util.ButtonBuilder; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.api.view.ViewContext; -import org.labkey.api.writer.HtmlWriter; -import org.labkey.list.controllers.ListController; -import org.springframework.validation.BindException; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; - -public class ListManagerSchema extends UserSchema -{ - private static final Set TABLE_NAMES; - public static final String LIST_MANAGER = "ListManager"; - public static final String SCHEMA_NAME = "ListManager"; - - static - { - Set names = new TreeSet<>(); - names.add(LIST_MANAGER); - TABLE_NAMES = Collections.unmodifiableSet(names); - } - - public ListManagerSchema(User user, Container container) - { - super(SCHEMA_NAME, "Contains list of lists", user, container, ExperimentService.get().getSchema()); - _hidden = true; - } - - public static void register(Module module) - { - DefaultSchema.registerProvider(SCHEMA_NAME, new DefaultSchema.SchemaProvider(module) - { - @Override - public boolean isAvailable(DefaultSchema schema, Module module) - { - return true; - } - - @Override - public QuerySchema createSchema(DefaultSchema schema, Module module) - { - return new ListManagerSchema(schema.getUser(), schema.getContainer()); - } - }); - } - - @Nullable - @Override - public TableInfo createTable(String name, ContainerFilter cf) - { - if (LIST_MANAGER.equalsIgnoreCase(name)) - { - TableInfo dbTable = getDbSchema().getTable("list"); - ListManagerTable table = new ListManagerTable(this, dbTable, cf); - table.setName("Available Lists"); - return table; - } - else - { - return null; - } - } - - @Override - protected QuerySettings createQuerySettings(String dataRegionName, String queryName, String viewName) - { - QuerySettings settings = super.createQuerySettings(dataRegionName, queryName, viewName); - if (LIST_MANAGER.equalsIgnoreCase(queryName)) - { - settings.setBaseSort(new Sort("Name")); - } - return settings; - } - - @Override - @NotNull - public QueryView createView(ViewContext context, @NotNull QuerySettings settings, BindException errors) - { - if (LIST_MANAGER.equalsIgnoreCase(settings.getQueryName())) - { - QueryView qv = new QueryView(this, settings, errors) - { - final QuerySettings s = getSettings(); - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - bar.add(super.createViewButton(getViewItemFilter())); - populateChartsReports(bar); - bar.add(createDeleteButton()); - List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); - bar.add(super.createExportButton(recordSelectorColumns)); - bar.add(createCreateNewListButton()); - bar.add(createImportListArchiveButton()); - bar.add(createExportArchiveButton()); - } - - private ActionButton createCreateNewListButton() - { - ActionURL urlCreate = new ActionURL(ListController.EditListDefinitionAction.class, getContainer()); - ActionButton btnCreate = new ActionButton("Create New List", urlCreate); - btnCreate.setDisplayPermission(DesignListPermission.class); - return btnCreate; - } - - private ButtonBuilder.Button createImportListArchiveButton() - { - ActionURL urlImport = new ActionURL(ListController.ImportListArchiveAction.class, getContainer()); - urlImport.addReturnUrl(getReturnUrl()); - ButtonBuilder.Button btnImport = new ButtonBuilder("Import List Archive") - .href(urlImport) - .build(); - btnImport.setDisplayPermission(DesignListPermission.class); - return btnImport; - } - - @Override - public ActionButton createDeleteButton() - { - if (!getContainer().hasPermission(getUser(), DesignListPermission.class)) - return null; - - if (!getContainer().hasPermission(getUser(), AdminPermission.class)) - { - ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); - urlDelete.addReturnUrl(getReturnUrl()); - ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); - btnDelete.setIconCls("trash"); - btnDelete.setActionType(ActionButton.Action.GET); - btnDelete.setRequiresSelection(true); - return btnDelete; - } - - MenuButton menuDelete = new MenuButton("Delete"); - menuDelete.setIconCls("trash"); - menuDelete.setDisplayPermission(DesignListPermission.class); - menuDelete.setRequiresSelection(true); - - ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); - urlDelete.addReturnUrl(getReturnUrl()); - menuDelete.addMenuItem("Delete List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + - urlDelete.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); - - - ActionURL urlTruncate = new ActionURL(ListController.TruncateListDataAction.class, getContainer()); - urlTruncate.addReturnUrl(getReturnUrl()); - menuDelete.addMenuItem("Delete All Data from List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + - urlTruncate.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); - - return menuDelete; - } - - private ActionButton createExportArchiveButton() - { - ActionURL urlExport = new ActionURL(ListController.ExportListArchiveAction.class, getContainer()); - ActionButton btnExport = new ActionButton(urlExport, "Export List Archive"); - btnExport.setRequiresSelection(true); - btnExport.setActionType(ActionButton.Action.POST); - btnExport.setDisplayPermission(DesignListPermission.class); - return btnExport; - } - - @Override - protected void addDetailsAndUpdateColumns(List ret, TableInfo table) - { - if (getContainer().hasPermission(getUser(), DesignListPermission.class)) - { - ret.add(new SimpleDisplayColumn() - { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); - ActionURL designUrl = new ActionURL(ListController.EditListDefinitionAction.class, c); - designUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); - out.write(LinkBuilder.labkeyLink("Design", designUrl)); - } - }); - } - - if (AuditLogService.get().isViewable()) - { - ret.add(new SimpleDisplayColumn() - { - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); - ActionURL historyUrl = new ActionURL(ListController.HistoryAction.class, c); - historyUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); - out.write(LinkBuilder.labkeyLink("View History", historyUrl)); - } - }); - } - } - }; - - qv.setAllowableContainerFilterTypes( - ContainerFilter.Type.Current, - ContainerFilter.Type.CurrentAndSubfolders, - ContainerFilter.Type.CurrentPlusProjectAndShared, - ContainerFilter.Type.AllFolders - ); - - return qv; - } - - return super.createView(context, settings, errors); - } - @Override - public Set getTableNames() - { - return TABLE_NAMES; - } -} +/* + * Copyright (c) 2014-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.Container; +import org.labkey.api.data.DataRegion; +import org.labkey.api.data.MenuButton; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.SimpleDisplayColumn; +import org.labkey.api.data.Sort; +import org.labkey.api.data.TableInfo; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.module.Module; +import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.QuerySchema; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryView; +import org.labkey.api.query.UserSchema; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.AdminPermission; +import org.labkey.api.util.ButtonBuilder; +import org.labkey.api.util.LinkBuilder; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.api.view.ViewContext; +import org.labkey.api.writer.HtmlWriter; +import org.labkey.list.controllers.ListController; +import org.springframework.validation.BindException; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +public class ListManagerSchema extends UserSchema +{ + private static final Set TABLE_NAMES; + public static final String LIST_MANAGER = "ListManager"; + public static final String SCHEMA_NAME = "ListManager"; + + static + { + Set names = new TreeSet<>(); + names.add(LIST_MANAGER); + TABLE_NAMES = Collections.unmodifiableSet(names); + } + + public ListManagerSchema(User user, Container container) + { + super(SCHEMA_NAME, "Contains list of lists", user, container, ExperimentService.get().getSchema()); + _hidden = true; + } + + public static void register(Module module) + { + DefaultSchema.registerProvider(SCHEMA_NAME, new DefaultSchema.SchemaProvider(module) + { + @Override + public boolean isAvailable(DefaultSchema schema, Module module) + { + return true; + } + + @Override + public QuerySchema createSchema(DefaultSchema schema, Module module) + { + return new ListManagerSchema(schema.getUser(), schema.getContainer()); + } + }); + } + + @Nullable + @Override + public TableInfo createTable(String name, ContainerFilter cf) + { + if (LIST_MANAGER.equalsIgnoreCase(name)) + { + TableInfo dbTable = getDbSchema().getTable("list"); + ListManagerTable table = new ListManagerTable(this, dbTable, cf); + table.setName("Available Lists"); + return table; + } + else + { + return null; + } + } + + @Override + protected QuerySettings createQuerySettings(String dataRegionName, String queryName, String viewName) + { + QuerySettings settings = super.createQuerySettings(dataRegionName, queryName, viewName); + if (LIST_MANAGER.equalsIgnoreCase(queryName)) + { + settings.setBaseSort(new Sort("Name")); + } + return settings; + } + + @Override + @NotNull + public QueryView createView(ViewContext context, @NotNull QuerySettings settings, BindException errors) + { + if (LIST_MANAGER.equalsIgnoreCase(settings.getQueryName())) + { + QueryView qv = new QueryView(this, settings, errors) + { + final QuerySettings s = getSettings(); + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + bar.add(super.createViewButton(getViewItemFilter())); + populateChartsReports(bar); + bar.add(createDeleteButton()); + List recordSelectorColumns = view.getDataRegion().getRecordSelectorValueColumns(); + bar.add(super.createExportButton(recordSelectorColumns)); + bar.add(createCreateNewListButton()); + bar.add(createImportListArchiveButton()); + bar.add(createExportArchiveButton()); + } + + private ActionButton createCreateNewListButton() + { + ActionURL urlCreate = new ActionURL(ListController.EditListDefinitionAction.class, getContainer()); + ActionButton btnCreate = new ActionButton("Create New List", urlCreate); + btnCreate.setDisplayPermission(DesignListPermission.class); + return btnCreate; + } + + private ButtonBuilder.Button createImportListArchiveButton() + { + ActionURL urlImport = new ActionURL(ListController.ImportListArchiveAction.class, getContainer()); + urlImport.addReturnUrl(getReturnUrl()); + ButtonBuilder.Button btnImport = new ButtonBuilder("Import List Archive") + .href(urlImport) + .build(); + btnImport.setDisplayPermission(DesignListPermission.class); + return btnImport; + } + + @Override + public ActionButton createDeleteButton() + { + if (!getContainer().hasPermission(getUser(), DesignListPermission.class)) + return null; + + if (!getContainer().hasPermission(getUser(), AdminPermission.class)) + { + ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); + urlDelete.addReturnUrl(getReturnUrl()); + ActionButton btnDelete = new ActionButton(urlDelete, "Delete"); + btnDelete.setIconCls("trash"); + btnDelete.setActionType(ActionButton.Action.GET); + btnDelete.setRequiresSelection(true); + return btnDelete; + } + + MenuButton menuDelete = new MenuButton("Delete"); + menuDelete.setIconCls("trash"); + menuDelete.setDisplayPermission(DesignListPermission.class); + menuDelete.setRequiresSelection(true); + + ActionURL urlDelete = new ActionURL(ListController.DeleteListDefinitionAction.class, getContainer()); + urlDelete.addReturnUrl(getReturnUrl()); + menuDelete.addMenuItem("Delete List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + + urlDelete.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); + + + ActionURL urlTruncate = new ActionURL(ListController.TruncateListDataAction.class, getContainer()); + urlTruncate.addReturnUrl(getReturnUrl()); + menuDelete.addMenuItem("Delete All Data from List", "if (verifySelected(" + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form, \"" + + urlTruncate.getLocalURIString() + "\", \"GET\", \"rows\")) " + DataRegion.getJavaScriptObjectReference(getDataRegionName()) + ".form.submit()"); + + return menuDelete; + } + + private ActionButton createExportArchiveButton() + { + ActionURL urlExport = new ActionURL(ListController.ExportListArchiveAction.class, getContainer()); + ActionButton btnExport = new ActionButton(urlExport, "Export List Archive"); + btnExport.setRequiresSelection(true); + btnExport.setActionType(ActionButton.Action.POST); + btnExport.setDisplayPermission(DesignListPermission.class); + return btnExport; + } + + @Override + protected void addDetailsAndUpdateColumns(List ret, TableInfo table) + { + if (getContainer().hasPermission(getUser(), DesignListPermission.class)) + { + ret.add(new SimpleDisplayColumn() + { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); + ActionURL designUrl = new ActionURL(ListController.EditListDefinitionAction.class, c); + designUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); + out.write(LinkBuilder.labkeyLink("Design", designUrl)); + } + }); + } + + if (AuditLogService.get().isViewable()) + { + ret.add(new SimpleDisplayColumn() + { + @Override + public void renderGridCellContents(RenderContext ctx, HtmlWriter out) + { + Container c = ContainerManager.getForId(ctx.get(FieldKey.fromParts("container")).toString()); + ActionURL historyUrl = new ActionURL(ListController.HistoryAction.class, c); + historyUrl.addParameter("listId", ctx.get(FieldKey.fromParts("listId")).toString()); + out.write(LinkBuilder.labkeyLink("View History", historyUrl)); + } + }); + } + } + }; + + qv.setAllowableContainerFilterTypes( + ContainerFilter.Type.Current, + ContainerFilter.Type.CurrentAndSubfolders, + ContainerFilter.Type.CurrentPlusProjectAndShared, + ContainerFilter.Type.AllFolders + ); + + return qv; + } + + return super.createView(context, settings, errors); + } + @Override + public Set getTableNames() + { + return TABLE_NAMES; + } +} diff --git a/list/src/org/labkey/list/model/ListQueryUpdateService.java b/list/src/org/labkey/list/model/ListQueryUpdateService.java index e3867f1893a..936cf53af26 100644 --- a/list/src/org/labkey/list/model/ListQueryUpdateService.java +++ b/list/src/org/labkey/list/model/ListQueryUpdateService.java @@ -1,885 +1,885 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.model; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.attachments.AttachmentFile; -import org.labkey.api.attachments.AttachmentParent; -import org.labkey.api.attachments.AttachmentParentFactory; -import org.labkey.api.attachments.AttachmentService; -import org.labkey.api.audit.AbstractAuditTypeProvider; -import org.labkey.api.audit.AuditLogService; -import org.labkey.api.audit.TransactionAuditProvider; -import org.labkey.api.collections.CaseInsensitiveHashMap; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.CompareType; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DbScope; -import org.labkey.api.data.LookupResolutionType; -import org.labkey.api.data.RuntimeSQLException; -import org.labkey.api.data.Selector.ForEachBatchBlock; -import org.labkey.api.data.SimpleFilter; -import org.labkey.api.data.Table; -import org.labkey.api.data.TableInfo; -import org.labkey.api.data.TableSelector; -import org.labkey.api.dataiterator.DataIteratorBuilder; -import org.labkey.api.dataiterator.DataIteratorContext; -import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; -import org.labkey.api.dataiterator.ImportProgress; -import org.labkey.api.exp.ObjectProperty; -import org.labkey.api.exp.PropertyType; -import org.labkey.api.exp.api.ExperimentService; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.exp.list.ListItem; -import org.labkey.api.exp.list.ListService; -import org.labkey.api.exp.property.Domain; -import org.labkey.api.exp.property.DomainProperty; -import org.labkey.api.exp.property.IPropertyValidator; -import org.labkey.api.exp.property.ValidatorContext; -import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.lists.permissions.ManagePicklistsPermission; -import org.labkey.api.query.AbstractQueryUpdateService; -import org.labkey.api.query.BatchValidationException; -import org.labkey.api.query.DefaultQueryUpdateService; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.InvalidKeyException; -import org.labkey.api.query.PropertyValidationError; -import org.labkey.api.query.QueryService; -import org.labkey.api.query.QueryUpdateServiceException; -import org.labkey.api.query.ValidationError; -import org.labkey.api.query.ValidationException; -import org.labkey.api.reader.DataLoader; -import org.labkey.api.security.ElevatedUser; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.DeletePermission; -import org.labkey.api.security.permissions.MoveEntitiesPermission; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.security.permissions.UpdatePermission; -import org.labkey.api.security.roles.EditorRole; -import org.labkey.api.usageMetrics.SimpleMetricsService; -import org.labkey.api.util.GUID; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Pair; -import org.labkey.api.util.StringUtilsLabKey; -import org.labkey.api.util.UnexpectedException; -import org.labkey.api.view.UnauthorizedException; -import org.labkey.api.writer.VirtualFile; -import org.labkey.list.view.ListItemAttachmentParent; - -import java.io.IOException; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.labkey.api.util.IntegerUtils.isIntegral; - -/** - * Implementation of QueryUpdateService for Lists - */ -public class ListQueryUpdateService extends DefaultQueryUpdateService -{ - private final ListDefinitionImpl _list; - private static final String ID = "entityId"; - - public ListQueryUpdateService(ListTable queryTable, TableInfo dbTable, @NotNull ListDefinition list) - { - super(queryTable, dbTable, createMVMapping(queryTable.getList().getDomain())); - _list = (ListDefinitionImpl) list; - } - - @Override - public void configureDataIteratorContext(DataIteratorContext context) - { - if (context.getInsertOption().batch) - { - context.setMaxRowErrors(100); - context.setFailFast(false); - } - - context.putConfigParameter(ConfigParameters.TrimStringRight, Boolean.TRUE); - } - - @Override - protected @Nullable AttachmentParentFactory getAttachmentParentFactory() - { - return new ListItemAttachmentParentFactory(); - } - - @Override - protected Map getRow(User user, Container container, Map listRow) throws InvalidKeyException - { - Map ret = null; - - if (null != listRow) - { - SimpleFilter keyFilter = getKeyFilter(listRow); - - if (null != keyFilter) - { - TableInfo queryTable = getQueryTable(); - Map raw = new TableSelector(queryTable, keyFilter, null).getMap(); - - if (null != raw && !raw.isEmpty()) - { - ret = new CaseInsensitiveHashMap<>(); - - // EntityId - ret.put("EntityId", raw.get("entityid")); - - for (DomainProperty prop : _list.getDomainOrThrow().getProperties()) - { - String propName = prop.getName(); - ColumnInfo column = queryTable.getColumn(propName); - Object value = column.getValue(raw); - if (value != null) - ret.put(propName, value); - } - } - } - } - - return ret; - } - - - @Override - public List> insertRows(User user, Container container, List> rows, BatchValidationException errors, - @Nullable Map configParameters, Map extraScriptContext) - { - for (Map row : rows) - { - aliasColumns(getColumnMapping(), row); - } - - DataIteratorContext context = getDataIteratorContext(errors, InsertOption.INSERT, configParameters); - recordDataIteratorUsed(configParameters); - List> result = this._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - - if (null != result) - { - ListManager mgr = ListManager.get(); - - for (Map row : result) - { - if (null != row.get(ID)) - { - // Audit each row - String entityId = (String) row.get(ID); - String newRecord = mgr.formatAuditItem(_list, user, row); - - mgr.addAuditEvent(_list, user, container, "A new list record was inserted", entityId, null, newRecord); - } - } - - if (!result.isEmpty() && !errors.hasErrors()) - mgr.indexList(_list); - } - - return result; - } - - private User getListUser(User user, Container container) - { - if (_list.isPicklist() && container.hasPermission(user, ManagePicklistsPermission.class)) - { - // if the list is a picklist and you have permission to manage picklists, that equates - // to having editor permission. - return ElevatedUser.ensureContextualRoles(container, user, Pair.of(DeletePermission.class, EditorRole.class)); - } - return user; - } - - @Override - protected @Nullable List> _insertRowsUsingDIB(User user, Container container, List> rows, DataIteratorContext context, @Nullable Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - return super._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - } - - @Override - protected @Nullable List> _updateRowsUsingDIB(User user, Container container, List> rows, - DataIteratorContext context, @Nullable Map extraScriptContext) - { - if (!hasPermission(user, UpdatePermission.class)) - throw new UnauthorizedException("You do not have permission to update data in this table."); - - return super._updateRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); - } - - public int insertUsingDataIterator(DataLoader loader, User user, Container container, BatchValidationException errors, @Nullable VirtualFile attachmentDir, - @Nullable ImportProgress progress, boolean supportAutoIncrementKey, InsertOption insertOption, LookupResolutionType lookupResolutionType) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - User updatedUser = getListUser(user, container); - DataIteratorContext context = new DataIteratorContext(errors); - context.setFailFast(false); - context.setInsertOption(insertOption); // this method is used by ListImporter and BackgroundListImporter - context.setSupportAutoIncrementKey(supportAutoIncrementKey); - context.setLookupResolutionType(lookupResolutionType); - setAttachmentDirectory(attachmentDir); - TableInfo ti = _list.getTable(updatedUser); - - if (null != ti) - { - try (DbScope.Transaction transaction = ti.getSchema().getScope().ensureTransaction()) - { - int imported = _importRowsUsingDIB(updatedUser, container, loader, null, context, new HashMap<>()); - - if (!errors.hasErrors()) - { - //Make entry to audit log if anything was inserted - if (imported > 0) - ListManager.get().addAuditEvent(_list, updatedUser, "Bulk " + (insertOption.updateOnly ? "updated " : (insertOption.mergeRows ? "imported " : "inserted ")) + imported + " rows to list."); - - transaction.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); - transaction.commit(); - - return imported; - } - - return 0; - } - } - - return 0; - } - - - @Override - public int mergeRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, - @Nullable Map configParameters, Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to update data in this table."); - - recordDataIteratorUsed(configParameters); - return _importRowsUsingDIB(getListUser(user, container), container, rows, null, getDataIteratorContext(errors, InsertOption.MERGE, configParameters), extraScriptContext); - } - - - @Override - public int importRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, - Map configParameters, Map extraScriptContext) - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to insert data into this table."); - - recordDataIteratorUsed(configParameters); - DataIteratorContext context = getDataIteratorContext(errors, InsertOption.IMPORT, configParameters); - int count = _importRowsUsingDIB(getListUser(user, container), container, rows, null, context, extraScriptContext); - if (count > 0 && !errors.hasErrors()) - ListManager.get().indexList(_list); - return count; - } - - @Override - public List> updateRows(User user, Container container, List> rows, List> oldKeys, - BatchValidationException errors, @Nullable Map configParameters, Map extraScriptContext) - throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to update data into this table."); - - List> result = super.updateRows(getListUser(user, container), container, rows, oldKeys, errors, configParameters, extraScriptContext); - if (!result.isEmpty()) - ListManager.get().indexList(_list); - return result; - } - - @Override - protected Map updateRow(User user, Container container, Map row, @NotNull Map oldRow, @Nullable Map configParameters) - throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException - { - // TODO: Check for equivalency so that attachments can be deleted etc. - - Map dps = new HashMap<>(); - for (DomainProperty dp : _list.getDomainOrThrow().getProperties()) - { - dps.put(dp.getPropertyURI(), dp); - } - - ValidatorContext validatorCache = new ValidatorContext(container, user); - - ListItm itm = new ListItm(); - itm.setEntityId((String) oldRow.get(ID)); - itm.setListId(_list.getListId()); - itm.setKey(oldRow.get(_list.getKeyName())); - - ListItem item = new ListItemImpl(_list, itm); - - if (item.getProperties() != null) - { - List errors = new ArrayList<>(); - for (Map.Entry entry : dps.entrySet()) - { - Object value = row.get(entry.getValue().getName()); - validateProperty(entry.getValue(), value, row, errors, validatorCache); - } - - if (!errors.isEmpty()) - throw new ValidationException(errors); - } - - // MVIndicators - Map rowCopy = new CaseInsensitiveHashMap<>(); - ArrayList modifiedAttachmentColumns = new ArrayList<>(); - ArrayList attachmentFiles = new ArrayList<>(); - - TableInfo qt = getQueryTable(); - for (Map.Entry r : row.entrySet()) - { - ColumnInfo column = qt.getColumn(FieldKey.fromParts(r.getKey())); - rowCopy.put(r.getKey(), r.getValue()); - - // 22747: Attachment columns - if (null != column) - { - DomainProperty dp = _list.getDomainOrThrow().getPropertyByURI(column.getPropertyURI()); - if (null != dp && isAttachmentProperty(dp)) - { - modifiedAttachmentColumns.add(column); - - // setup any new attachments - if (r.getValue() instanceof AttachmentFile file) - { - if (null != file.getFilename()) - attachmentFiles.add(file); - } - else if (r.getValue() != null && !StringUtils.isEmpty(String.valueOf(r.getValue()))) - { - throw new ValidationException("Cannot upload '" + r.getValue() + "' to Attachment type field '" + r.getKey() + "'."); - } - } - } - } - - // Attempt to include key from oldRow if not found in row (As stated in the QUS Interface) - Object newRowKey = getField(rowCopy, _list.getKeyName()); - Object oldRowKey = getField(oldRow, _list.getKeyName()); - - if (null == newRowKey && null != oldRowKey) - rowCopy.put(_list.getKeyName(), oldRowKey); - - Map result = super.updateRow(getListUser(user, container), container, rowCopy, oldRow, true, false); - - if (null != result) - { - result = getRow(user, container, result); - - if (null != result && null != result.get(ID)) - { - ListManager mgr = ListManager.get(); - String entityId = (String) result.get(ID); - - try - { - // Remove prior attachment -- only includes columns which are modified in this update - for (ColumnInfo col : modifiedAttachmentColumns) - { - Object value = oldRow.get(col.getName()); - if (null != value) - { - AttachmentService.get().deleteAttachment(new ListItemAttachmentParent(entityId, _list.getContainer()), value.toString(), user); - } - } - - // Update attachments - if (!attachmentFiles.isEmpty()) - AttachmentService.get().addAttachments(new ListItemAttachmentParent(entityId, _list.getContainer()), attachmentFiles, user); - } - catch (AttachmentService.DuplicateFilenameException | AttachmentService.FileTooLargeException e) - { - // issues 21503, 28633: turn these into a validation exception to get a nicer error - throw new ValidationException(e.getMessage()); - } - catch (IOException e) - { - throw UnexpectedException.wrap(e); - } - finally - { - for (AttachmentFile attachmentFile : attachmentFiles) - { - try { attachmentFile.closeInputStream(); } catch (IOException ignored) {} - } - } - - String oldRecord = mgr.formatAuditItem(_list, user, oldRow); - String newRecord = mgr.formatAuditItem(_list, user, result); - - // Audit - mgr.addAuditEvent(_list, user, container, "An existing list record was modified", entityId, oldRecord, newRecord); - } - } - - return result; - } - - // TODO: Consolidate with ColumnValidator and OntologyManager.validateProperty() - private boolean validateProperty(DomainProperty prop, Object value, Map newRow, List errors, ValidatorContext validatorCache) - { - //check for isRequired - if (prop.isRequired()) - { - // for mv indicator columns either an indicator or a field value is sufficient - boolean hasMvIndicator = prop.isMvEnabled() && (value instanceof ObjectProperty && ((ObjectProperty)value).getMvIndicator() != null); - if (!hasMvIndicator && (null == value || (value instanceof ObjectProperty && ((ObjectProperty)value).value() == null))) - { - if (newRow.containsKey(prop.getName()) && newRow.get(prop.getName()) == null) - { - errors.add(new PropertyValidationError("The field '" + prop.getName() + "' is required.", prop.getName())); - return false; - } - } - } - - if (null != value) - { - for (IPropertyValidator validator : prop.getValidators()) - { - if (!validator.validate(prop.getPropertyDescriptor(), value, errors, validatorCache)) - return false; - } - } - - return true; - } - - private record ListRecord(Object key, String entityId) { } - - @Override - public Map moveRows( - User _user, - Container container, - Container targetContainer, - List> rows, - BatchValidationException errors, - @Nullable Map configParameters, - @Nullable Map extraScriptContext - ) throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException - { - // Ensure the list is in scope for the target container - if (null == ListService.get().getList(targetContainer, _list.getName(), true)) - { - errors.addRowError(new ValidationException(String.format("List '%s' is not accessible from folder %s.", _list.getName(), targetContainer.getPath()))); - throw errors; - } - - User user = getListUser(_user, container); - Map> containerRows = getListRowsForMoveRows(container, user, targetContainer, rows, errors); - if (errors.hasErrors()) - throw errors; - - int fileAttachmentsMovedCount = 0; - int listAuditEventsCreatedCount = 0; - int listAuditEventsMovedCount = 0; - int listRecordsCount = 0; - int queryAuditEventsMovedCount = 0; - - if (containerRows.isEmpty()) - return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); - - AuditBehaviorType auditBehavior = configParameters != null ? (AuditBehaviorType) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior) : null; - String auditUserComment = configParameters != null ? (String) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment) : null; - boolean hasAttachmentProperties = _list.getDomainOrThrow() - .getProperties() - .stream() - .anyMatch(prop -> PropertyType.ATTACHMENT.equals(prop.getPropertyType())); - - ListAuditProvider listAuditProvider = new ListAuditProvider(); - final int BATCH_SIZE = 5_000; - boolean isAuditEnabled = auditBehavior != null && AuditBehaviorType.NONE != auditBehavior; - - try (DbScope.Transaction tx = getDbTable().getSchema().getScope().ensureTransaction()) - { - if (isAuditEnabled && tx.getAuditEvent() == null) - { - TransactionAuditProvider.TransactionAuditEvent auditEvent = createTransactionAuditEvent(targetContainer, QueryService.AuditAction.UPDATE); - auditEvent.updateCommentRowCount(containerRows.values().stream().mapToInt(List::size).sum()); - AbstractQueryUpdateService.addTransactionAuditEvent(tx, user, auditEvent); - } - - List listAuditEvents = new ArrayList<>(); - - for (GUID containerId : containerRows.keySet()) - { - Container sourceContainer = ContainerManager.getForId(containerId); - if (sourceContainer == null) - throw new InvalidKeyException("Container '" + containerId + "' does not exist."); - - if (!sourceContainer.hasPermission(user, MoveEntitiesPermission.class)) - throw new UnauthorizedException("You do not have permission to move list records out of '" + sourceContainer.getName() + "'."); - - TableInfo listTable = _list.getTable(user, sourceContainer); - if (listTable == null) - throw new QueryUpdateServiceException(String.format("Failed to retrieve table for list '%s' in folder %s.", _list.getName(), sourceContainer.getPath())); - - List records = containerRows.get(containerId); - int numRecords = records.size(); - - for (int start = 0; start < numRecords; start += BATCH_SIZE) - { - int end = Math.min(start + BATCH_SIZE, numRecords); - List batch = records.subList(start, end); - List rowPks = batch.stream().map(ListRecord::key).toList(); - - // Before trigger per batch - Map extraContext = Map.of("targetContainer", targetContainer, "keys", rowPks); - listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, true, errors, extraContext); - if (errors.hasErrors()) - throw errors; - - listRecordsCount += Table.updateContainer(getDbTable(), _list.getKeyName(), rowPks, targetContainer, user, true); - if (errors.hasErrors()) - throw errors; - - if (hasAttachmentProperties) - { - fileAttachmentsMovedCount += moveAttachments(user, sourceContainer, targetContainer, batch, errors); - if (errors.hasErrors()) - throw errors; - } - - queryAuditEventsMovedCount += QueryService.get().moveAuditEvents(targetContainer, rowPks, ListQuerySchema.NAME, _list.getName()); - listAuditEventsMovedCount += listAuditProvider.moveEvents(targetContainer, batch.stream().map(ListRecord::entityId).toList()); - - // Detailed audit events per row - if (AuditBehaviorType.DETAILED == listTable.getEffectiveAuditBehavior(auditBehavior)) - listAuditEventsCreatedCount += addDetailedMoveAuditEvents(user, sourceContainer, targetContainer, batch); - - // After trigger per batch - listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, false, errors, extraContext); - if (errors.hasErrors()) - throw errors; - } - - // Create a summary audit event for the source container - if (isAuditEnabled) - { - String comment = String.format("Moved %s to %s", StringUtilsLabKey.pluralize(numRecords, "row"), targetContainer.getPath()); - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(sourceContainer, comment, _list); - event.setUserComment(auditUserComment); - listAuditEvents.add(event); - } - - // Create a summary audit event for the target container - if (isAuditEnabled) - { - String comment = String.format("Moved %s from %s", StringUtilsLabKey.pluralize(numRecords, "row"), sourceContainer.getPath()); - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, comment, _list); - event.setUserComment(auditUserComment); - listAuditEvents.add(event); - } - } - - if (!listAuditEvents.isEmpty()) - { - AuditLogService.get().addEvents(user, listAuditEvents, true); - listAuditEventsCreatedCount += listAuditEvents.size(); - } - - tx.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); - - tx.commit(); - - SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "moveEntities", "list"); - } - - return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); - } - - private Map moveRowsCounts(int fileAttachmentsMovedCount, int listAuditEventsCreated, int listAuditEventsMoved, int listRecords, int queryAuditEventsMoved) - { - return Map.of( - "fileAttachmentsMoved", fileAttachmentsMovedCount, - "listAuditEventsCreated", listAuditEventsCreated, - "listAuditEventsMoved", listAuditEventsMoved, - "listRecords", listRecords, - "queryAuditEventsMoved", queryAuditEventsMoved - ); - } - - private Map> getListRowsForMoveRows( - Container container, - User user, - Container targetContainer, - List> rows, - BatchValidationException errors - ) throws QueryUpdateServiceException - { - if (rows.isEmpty()) - return Collections.emptyMap(); - - String keyName = _list.getKeyName(); - List keys = new ArrayList<>(); - for (var row : rows) - { - Object key = getField(row, keyName); - if (key == null) - { - errors.addRowError(new ValidationException("Key field value required for moving list rows.")); - return Collections.emptyMap(); - } - - keys.add(getKeyFilterValue(key)); - } - - SimpleFilter filter = new SimpleFilter(); - FieldKey fieldKey = FieldKey.fromParts(keyName); - filter.addInClause(fieldKey, keys); - filter.addCondition(FieldKey.fromParts("Container"), targetContainer.getId(), CompareType.NEQ); - - // Request all rows without a container filter so that rows are more easily resolved across the list scope. - // Read permissions are subsequently checked upon loading a row. - TableInfo table = _list.getTable(user, container, ContainerFilter.getUnsafeEverythingFilter()); - if (table == null) - throw new QueryUpdateServiceException(String.format("Failed to resolve table for list %s in %s", _list.getName(), container.getPath())); - - Map> containerRows = new HashMap<>(); - try (var result = new TableSelector(table, PageFlowUtil.set(keyName, "Container", "EntityId"), filter, null).getResults()) - { - while (result.next()) - { - GUID containerId = new GUID(result.getString("Container")); - if (!containerRows.containsKey(containerId)) - { - var c = ContainerManager.getForId(containerId); - if (c == null) - throw new QueryUpdateServiceException(String.format("Failed to resolve container for row in list %s in %s.", _list.getName(), container.getPath())); - else if (!c.hasPermission(user, ReadPermission.class)) - throw new UnauthorizedException("You do not have permission to read list rows in all source containers."); - } - - containerRows.computeIfAbsent(containerId, k -> new ArrayList<>()); - containerRows.get(containerId).add(new ListRecord(result.getObject(fieldKey), result.getString("EntityId"))); - } - } - catch (SQLException e) - { - throw new RuntimeSQLException(e); - } - - return containerRows; - } - - private int moveAttachments(User user, Container sourceContainer, Container targetContainer, List records, BatchValidationException errors) - { - List parents = new ArrayList<>(); - for (ListRecord record : records) - parents.add(new ListItemAttachmentParent(record.entityId, sourceContainer)); - - int count = 0; - try - { - count = AttachmentService.get().moveAttachments(targetContainer, parents, user); - } - catch (IOException e) - { - errors.addRowError(new ValidationException("Failed to move attachments when moving list rows. Error: " + e.getMessage())); - } - - return count; - } - - private int addDetailedMoveAuditEvents(User user, Container sourceContainer, Container targetContainer, List records) - { - List auditEvents = new ArrayList<>(records.size()); - String keyName = _list.getKeyName(); - String sourcePath = sourceContainer.getPath(); - String targetPath = targetContainer.getPath(); - - for (ListRecord record : records) - { - ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, "An existing list record was moved", _list); - event.setListItemEntityId(record.entityId); - event.setOldRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", sourcePath, keyName, record.key.toString()))); - event.setNewRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", targetPath, keyName, record.key.toString()))); - auditEvents.add(event); - } - - AuditLogService.get().addEvents(user, auditEvents, true); - - return auditEvents.size(); - } - - @Override - protected Map deleteRow(User user, Container container, Map oldRowMap) throws InvalidKeyException, QueryUpdateServiceException, SQLException - { - if (!_list.isVisible(user)) - throw new UnauthorizedException("You do not have permission to delete data from this table."); - - Map result = super.deleteRow(getListUser(user, container), container, oldRowMap); - - if (null != result) - { - String entityId = (String) result.get(ID); - - if (null != entityId) - { - ListManager mgr = ListManager.get(); - String deletedRecord = mgr.formatAuditItem(_list, user, result); - - // Audit - mgr.addAuditEvent(_list, user, container, "An existing list record was deleted", entityId, deletedRecord, null); - - // Remove attachments - if (hasAttachmentProperties()) - AttachmentService.get().deleteAttachments(new ListItemAttachmentParent(entityId, container)); - - // Clean up Search indexer - if (!result.isEmpty()) - mgr.deleteItemIndex(_list, entityId); - } - } - - return result; - } - - - // Deletes attachments & discussions, and removes list documents from full-text search index. - public void deleteRelatedListData(final User user, final Container container) - { - // Unindex all item docs and the entire list doc - ListManager.get().deleteIndexedList(_list); - - // Delete attachments and discussions associated with a list in batches of 1,000 - new TableSelector(getDbTable(), Collections.singleton("entityId"), SimpleFilter.createContainerFilter(container), null).forEachBatch(String.class, 1000, new ForEachBatchBlock<>() - { - @Override - public boolean accept(String entityId) - { - return null != entityId; - } - - @Override - public void exec(List entityIds) - { - // delete the related list data for this block - deleteRelatedListData(user, container, entityIds); - } - }); - } - - // delete the related list data for this block of entityIds - private void deleteRelatedListData(User user, Container container, List entityIds) - { - // Build up set of entityIds and AttachmentParents - List attachmentParents = new ArrayList<>(); - - // Delete Attachments - if (hasAttachmentProperties()) - { - for (String entityId : entityIds) - { - attachmentParents.add(new ListItemAttachmentParent(entityId, container)); - } - AttachmentService.get().deleteAttachments(attachmentParents); - } - } - - @Override - protected int truncateRows(User user, Container container) throws QueryUpdateServiceException, SQLException - { - int result; - try (DbScope.Transaction transaction = getDbTable().getSchema().getScope().ensureTransaction()) - { - deleteRelatedListData(user, container); - result = super.truncateRows(getListUser(user, container), container); - transaction.addCommitTask(() -> ListManager.get().addAuditEvent(_list, user, "Deleted " + result + " rows from list."), DbScope.CommitTaskOption.POSTCOMMIT); - transaction.commit(); - } - - return result; - } - - @Nullable - public SimpleFilter getKeyFilter(Map map) throws InvalidKeyException - { - String keyName = _list.getKeyName(); - Object key = getField(map, keyName); - - if (null == key) - { - // Auto-increment lists might not provide a key so allow them to pass through - if (ListDefinition.KeyType.AutoIncrementInteger.equals(_list.getKeyType())) - return null; - throw new InvalidKeyException("No " + keyName + " provided for list \"" + _list.getName() + "\""); - } - - return new SimpleFilter(FieldKey.fromParts(keyName), getKeyFilterValue(key)); - } - - @NotNull - private Object getKeyFilterValue(@NotNull Object key) - { - ListDefinition.KeyType type = _list.getKeyType(); - - // Check the type of the list to ensure proper casting of the key type - if (ListDefinition.KeyType.Integer.equals(type) || ListDefinition.KeyType.AutoIncrementInteger.equals(type)) - return isIntegral(key) ? key : Integer.valueOf(key.toString()); - - return key.toString(); - } - - @Nullable - private Object getField(Map map, String key) - { - /* TODO: this is very strange, we have a TableInfo we should be using its ColumnInfo objects to figure out aliases, we don't need to guess */ - Object value = map.get(key); - - if (null == value) - value = map.get(key + "_"); - - if (null == value) - value = map.get(getDbTable().getSqlDialect().legalNameFromName(key)); - - return value; - } - - /** - * Delegate class to generate an AttachmentParent - */ - public static class ListItemAttachmentParentFactory implements AttachmentParentFactory - { - @Override - public AttachmentParent generateAttachmentParent(String entityId, Container c) - { - return new ListItemAttachmentParent(entityId, c); - } - } - - /** - * Get Domain from list definition, unless null then get from super - */ - @Override - protected Domain getDomain() - { - return _list != null? - _list.getDomain() : - super.getDomain(); - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.model; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.attachments.AttachmentFile; +import org.labkey.api.attachments.AttachmentParent; +import org.labkey.api.attachments.AttachmentParentFactory; +import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.audit.AbstractAuditTypeProvider; +import org.labkey.api.audit.AuditLogService; +import org.labkey.api.audit.TransactionAuditProvider; +import org.labkey.api.collections.CaseInsensitiveHashMap; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.CompareType; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DbScope; +import org.labkey.api.data.LookupResolutionType; +import org.labkey.api.data.RuntimeSQLException; +import org.labkey.api.data.Selector.ForEachBatchBlock; +import org.labkey.api.data.SimpleFilter; +import org.labkey.api.data.Table; +import org.labkey.api.data.TableInfo; +import org.labkey.api.data.TableSelector; +import org.labkey.api.dataiterator.DataIteratorBuilder; +import org.labkey.api.dataiterator.DataIteratorContext; +import org.labkey.api.dataiterator.DetailedAuditLogDataIterator; +import org.labkey.api.dataiterator.ImportProgress; +import org.labkey.api.exp.ObjectProperty; +import org.labkey.api.exp.PropertyType; +import org.labkey.api.exp.api.ExperimentService; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.exp.list.ListItem; +import org.labkey.api.exp.list.ListService; +import org.labkey.api.exp.property.Domain; +import org.labkey.api.exp.property.DomainProperty; +import org.labkey.api.exp.property.IPropertyValidator; +import org.labkey.api.exp.property.ValidatorContext; +import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.lists.permissions.ManagePicklistsPermission; +import org.labkey.api.query.AbstractQueryUpdateService; +import org.labkey.api.query.BatchValidationException; +import org.labkey.api.query.DefaultQueryUpdateService; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.InvalidKeyException; +import org.labkey.api.query.PropertyValidationError; +import org.labkey.api.query.QueryService; +import org.labkey.api.query.QueryUpdateServiceException; +import org.labkey.api.query.ValidationError; +import org.labkey.api.query.ValidationException; +import org.labkey.api.reader.DataLoader; +import org.labkey.api.security.ElevatedUser; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.DeletePermission; +import org.labkey.api.security.permissions.MoveEntitiesPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.security.permissions.UpdatePermission; +import org.labkey.api.security.roles.EditorRole; +import org.labkey.api.usageMetrics.SimpleMetricsService; +import org.labkey.api.util.GUID; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Pair; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.UnexpectedException; +import org.labkey.api.view.UnauthorizedException; +import org.labkey.api.writer.VirtualFile; +import org.labkey.list.view.ListItemAttachmentParent; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.labkey.api.util.IntegerUtils.isIntegral; + +/** + * Implementation of QueryUpdateService for Lists + */ +public class ListQueryUpdateService extends DefaultQueryUpdateService +{ + private final ListDefinitionImpl _list; + private static final String ID = "entityId"; + + public ListQueryUpdateService(ListTable queryTable, TableInfo dbTable, @NotNull ListDefinition list) + { + super(queryTable, dbTable, createMVMapping(queryTable.getList().getDomain())); + _list = (ListDefinitionImpl) list; + } + + @Override + public void configureDataIteratorContext(DataIteratorContext context) + { + if (context.getInsertOption().batch) + { + context.setMaxRowErrors(100); + context.setFailFast(false); + } + + context.putConfigParameter(ConfigParameters.TrimStringRight, Boolean.TRUE); + } + + @Override + protected @Nullable AttachmentParentFactory getAttachmentParentFactory() + { + return new ListItemAttachmentParentFactory(); + } + + @Override + protected Map getRow(User user, Container container, Map listRow) throws InvalidKeyException + { + Map ret = null; + + if (null != listRow) + { + SimpleFilter keyFilter = getKeyFilter(listRow); + + if (null != keyFilter) + { + TableInfo queryTable = getQueryTable(); + Map raw = new TableSelector(queryTable, keyFilter, null).getMap(); + + if (null != raw && !raw.isEmpty()) + { + ret = new CaseInsensitiveHashMap<>(); + + // EntityId + ret.put("EntityId", raw.get("entityid")); + + for (DomainProperty prop : _list.getDomainOrThrow().getProperties()) + { + String propName = prop.getName(); + ColumnInfo column = queryTable.getColumn(propName); + Object value = column.getValue(raw); + if (value != null) + ret.put(propName, value); + } + } + } + } + + return ret; + } + + + @Override + public List> insertRows(User user, Container container, List> rows, BatchValidationException errors, + @Nullable Map configParameters, Map extraScriptContext) + { + for (Map row : rows) + { + aliasColumns(getColumnMapping(), row); + } + + DataIteratorContext context = getDataIteratorContext(errors, InsertOption.INSERT, configParameters); + recordDataIteratorUsed(configParameters); + List> result = this._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + + if (null != result) + { + ListManager mgr = ListManager.get(); + + for (Map row : result) + { + if (null != row.get(ID)) + { + // Audit each row + String entityId = (String) row.get(ID); + String newRecord = mgr.formatAuditItem(_list, user, row); + + mgr.addAuditEvent(_list, user, container, "A new list record was inserted", entityId, null, newRecord); + } + } + + if (!result.isEmpty() && !errors.hasErrors()) + mgr.indexList(_list); + } + + return result; + } + + private User getListUser(User user, Container container) + { + if (_list.isPicklist() && container.hasPermission(user, ManagePicklistsPermission.class)) + { + // if the list is a picklist and you have permission to manage picklists, that equates + // to having editor permission. + return ElevatedUser.ensureContextualRoles(container, user, Pair.of(DeletePermission.class, EditorRole.class)); + } + return user; + } + + @Override + protected @Nullable List> _insertRowsUsingDIB(User user, Container container, List> rows, DataIteratorContext context, @Nullable Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + return super._insertRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + } + + @Override + protected @Nullable List> _updateRowsUsingDIB(User user, Container container, List> rows, + DataIteratorContext context, @Nullable Map extraScriptContext) + { + if (!hasPermission(user, UpdatePermission.class)) + throw new UnauthorizedException("You do not have permission to update data in this table."); + + return super._updateRowsUsingDIB(getListUser(user, container), container, rows, context, extraScriptContext); + } + + public int insertUsingDataIterator(DataLoader loader, User user, Container container, BatchValidationException errors, @Nullable VirtualFile attachmentDir, + @Nullable ImportProgress progress, boolean supportAutoIncrementKey, InsertOption insertOption, LookupResolutionType lookupResolutionType) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + User updatedUser = getListUser(user, container); + DataIteratorContext context = new DataIteratorContext(errors); + context.setFailFast(false); + context.setInsertOption(insertOption); // this method is used by ListImporter and BackgroundListImporter + context.setSupportAutoIncrementKey(supportAutoIncrementKey); + context.setLookupResolutionType(lookupResolutionType); + setAttachmentDirectory(attachmentDir); + TableInfo ti = _list.getTable(updatedUser); + + if (null != ti) + { + try (DbScope.Transaction transaction = ti.getSchema().getScope().ensureTransaction()) + { + int imported = _importRowsUsingDIB(updatedUser, container, loader, null, context, new HashMap<>()); + + if (!errors.hasErrors()) + { + //Make entry to audit log if anything was inserted + if (imported > 0) + ListManager.get().addAuditEvent(_list, updatedUser, "Bulk " + (insertOption.updateOnly ? "updated " : (insertOption.mergeRows ? "imported " : "inserted ")) + imported + " rows to list."); + + transaction.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); + transaction.commit(); + + return imported; + } + + return 0; + } + } + + return 0; + } + + + @Override + public int mergeRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, + @Nullable Map configParameters, Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to update data in this table."); + + recordDataIteratorUsed(configParameters); + return _importRowsUsingDIB(getListUser(user, container), container, rows, null, getDataIteratorContext(errors, InsertOption.MERGE, configParameters), extraScriptContext); + } + + + @Override + public int importRows(User user, Container container, DataIteratorBuilder rows, BatchValidationException errors, + Map configParameters, Map extraScriptContext) + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to insert data into this table."); + + recordDataIteratorUsed(configParameters); + DataIteratorContext context = getDataIteratorContext(errors, InsertOption.IMPORT, configParameters); + int count = _importRowsUsingDIB(getListUser(user, container), container, rows, null, context, extraScriptContext); + if (count > 0 && !errors.hasErrors()) + ListManager.get().indexList(_list); + return count; + } + + @Override + public List> updateRows(User user, Container container, List> rows, List> oldKeys, + BatchValidationException errors, @Nullable Map configParameters, Map extraScriptContext) + throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException, SQLException + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to update data into this table."); + + List> result = super.updateRows(getListUser(user, container), container, rows, oldKeys, errors, configParameters, extraScriptContext); + if (!result.isEmpty()) + ListManager.get().indexList(_list); + return result; + } + + @Override + protected Map updateRow(User user, Container container, Map row, @NotNull Map oldRow, @Nullable Map configParameters) + throws InvalidKeyException, ValidationException, QueryUpdateServiceException, SQLException + { + // TODO: Check for equivalency so that attachments can be deleted etc. + + Map dps = new HashMap<>(); + for (DomainProperty dp : _list.getDomainOrThrow().getProperties()) + { + dps.put(dp.getPropertyURI(), dp); + } + + ValidatorContext validatorCache = new ValidatorContext(container, user); + + ListItm itm = new ListItm(); + itm.setEntityId((String) oldRow.get(ID)); + itm.setListId(_list.getListId()); + itm.setKey(oldRow.get(_list.getKeyName())); + + ListItem item = new ListItemImpl(_list, itm); + + if (item.getProperties() != null) + { + List errors = new ArrayList<>(); + for (Map.Entry entry : dps.entrySet()) + { + Object value = row.get(entry.getValue().getName()); + validateProperty(entry.getValue(), value, row, errors, validatorCache); + } + + if (!errors.isEmpty()) + throw new ValidationException(errors); + } + + // MVIndicators + Map rowCopy = new CaseInsensitiveHashMap<>(); + ArrayList modifiedAttachmentColumns = new ArrayList<>(); + ArrayList attachmentFiles = new ArrayList<>(); + + TableInfo qt = getQueryTable(); + for (Map.Entry r : row.entrySet()) + { + ColumnInfo column = qt.getColumn(FieldKey.fromParts(r.getKey())); + rowCopy.put(r.getKey(), r.getValue()); + + // 22747: Attachment columns + if (null != column) + { + DomainProperty dp = _list.getDomainOrThrow().getPropertyByURI(column.getPropertyURI()); + if (null != dp && isAttachmentProperty(dp)) + { + modifiedAttachmentColumns.add(column); + + // setup any new attachments + if (r.getValue() instanceof AttachmentFile file) + { + if (null != file.getFilename()) + attachmentFiles.add(file); + } + else if (r.getValue() != null && !StringUtils.isEmpty(String.valueOf(r.getValue()))) + { + throw new ValidationException("Cannot upload '" + r.getValue() + "' to Attachment type field '" + r.getKey() + "'."); + } + } + } + } + + // Attempt to include key from oldRow if not found in row (As stated in the QUS Interface) + Object newRowKey = getField(rowCopy, _list.getKeyName()); + Object oldRowKey = getField(oldRow, _list.getKeyName()); + + if (null == newRowKey && null != oldRowKey) + rowCopy.put(_list.getKeyName(), oldRowKey); + + Map result = super.updateRow(getListUser(user, container), container, rowCopy, oldRow, true, false); + + if (null != result) + { + result = getRow(user, container, result); + + if (null != result && null != result.get(ID)) + { + ListManager mgr = ListManager.get(); + String entityId = (String) result.get(ID); + + try + { + // Remove prior attachment -- only includes columns which are modified in this update + for (ColumnInfo col : modifiedAttachmentColumns) + { + Object value = oldRow.get(col.getName()); + if (null != value) + { + AttachmentService.get().deleteAttachment(new ListItemAttachmentParent(entityId, _list.getContainer()), value.toString(), user); + } + } + + // Update attachments + if (!attachmentFiles.isEmpty()) + AttachmentService.get().addAttachments(new ListItemAttachmentParent(entityId, _list.getContainer()), attachmentFiles, user); + } + catch (AttachmentService.DuplicateFilenameException | AttachmentService.FileTooLargeException e) + { + // issues 21503, 28633: turn these into a validation exception to get a nicer error + throw new ValidationException(e.getMessage()); + } + catch (IOException e) + { + throw UnexpectedException.wrap(e); + } + finally + { + for (AttachmentFile attachmentFile : attachmentFiles) + { + try { attachmentFile.closeInputStream(); } catch (IOException ignored) {} + } + } + + String oldRecord = mgr.formatAuditItem(_list, user, oldRow); + String newRecord = mgr.formatAuditItem(_list, user, result); + + // Audit + mgr.addAuditEvent(_list, user, container, "An existing list record was modified", entityId, oldRecord, newRecord); + } + } + + return result; + } + + // TODO: Consolidate with ColumnValidator and OntologyManager.validateProperty() + private boolean validateProperty(DomainProperty prop, Object value, Map newRow, List errors, ValidatorContext validatorCache) + { + //check for isRequired + if (prop.isRequired()) + { + // for mv indicator columns either an indicator or a field value is sufficient + boolean hasMvIndicator = prop.isMvEnabled() && (value instanceof ObjectProperty && ((ObjectProperty)value).getMvIndicator() != null); + if (!hasMvIndicator && (null == value || (value instanceof ObjectProperty && ((ObjectProperty)value).value() == null))) + { + if (newRow.containsKey(prop.getName()) && newRow.get(prop.getName()) == null) + { + errors.add(new PropertyValidationError("The field '" + prop.getName() + "' is required.", prop.getName())); + return false; + } + } + } + + if (null != value) + { + for (IPropertyValidator validator : prop.getValidators()) + { + if (!validator.validate(prop.getPropertyDescriptor(), value, errors, validatorCache)) + return false; + } + } + + return true; + } + + private record ListRecord(Object key, String entityId) { } + + @Override + public Map moveRows( + User _user, + Container container, + Container targetContainer, + List> rows, + BatchValidationException errors, + @Nullable Map configParameters, + @Nullable Map extraScriptContext + ) throws InvalidKeyException, BatchValidationException, QueryUpdateServiceException + { + // Ensure the list is in scope for the target container + if (null == ListService.get().getList(targetContainer, _list.getName(), true)) + { + errors.addRowError(new ValidationException(String.format("List '%s' is not accessible from folder %s.", _list.getName(), targetContainer.getPath()))); + throw errors; + } + + User user = getListUser(_user, container); + Map> containerRows = getListRowsForMoveRows(container, user, targetContainer, rows, errors); + if (errors.hasErrors()) + throw errors; + + int fileAttachmentsMovedCount = 0; + int listAuditEventsCreatedCount = 0; + int listAuditEventsMovedCount = 0; + int listRecordsCount = 0; + int queryAuditEventsMovedCount = 0; + + if (containerRows.isEmpty()) + return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); + + AuditBehaviorType auditBehavior = configParameters != null ? (AuditBehaviorType) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditBehavior) : null; + String auditUserComment = configParameters != null ? (String) configParameters.get(DetailedAuditLogDataIterator.AuditConfigs.AuditUserComment) : null; + boolean hasAttachmentProperties = _list.getDomainOrThrow() + .getProperties() + .stream() + .anyMatch(prop -> PropertyType.ATTACHMENT.equals(prop.getPropertyType())); + + ListAuditProvider listAuditProvider = new ListAuditProvider(); + final int BATCH_SIZE = 5_000; + boolean isAuditEnabled = auditBehavior != null && AuditBehaviorType.NONE != auditBehavior; + + try (DbScope.Transaction tx = getDbTable().getSchema().getScope().ensureTransaction()) + { + if (isAuditEnabled && tx.getAuditEvent() == null) + { + TransactionAuditProvider.TransactionAuditEvent auditEvent = createTransactionAuditEvent(targetContainer, QueryService.AuditAction.UPDATE); + auditEvent.updateCommentRowCount(containerRows.values().stream().mapToInt(List::size).sum()); + AbstractQueryUpdateService.addTransactionAuditEvent(tx, user, auditEvent); + } + + List listAuditEvents = new ArrayList<>(); + + for (GUID containerId : containerRows.keySet()) + { + Container sourceContainer = ContainerManager.getForId(containerId); + if (sourceContainer == null) + throw new InvalidKeyException("Container '" + containerId + "' does not exist."); + + if (!sourceContainer.hasPermission(user, MoveEntitiesPermission.class)) + throw new UnauthorizedException("You do not have permission to move list records out of '" + sourceContainer.getName() + "'."); + + TableInfo listTable = _list.getTable(user, sourceContainer); + if (listTable == null) + throw new QueryUpdateServiceException(String.format("Failed to retrieve table for list '%s' in folder %s.", _list.getName(), sourceContainer.getPath())); + + List records = containerRows.get(containerId); + int numRecords = records.size(); + + for (int start = 0; start < numRecords; start += BATCH_SIZE) + { + int end = Math.min(start + BATCH_SIZE, numRecords); + List batch = records.subList(start, end); + List rowPks = batch.stream().map(ListRecord::key).toList(); + + // Before trigger per batch + Map extraContext = Map.of("targetContainer", targetContainer, "keys", rowPks); + listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, true, errors, extraContext); + if (errors.hasErrors()) + throw errors; + + listRecordsCount += Table.updateContainer(getDbTable(), _list.getKeyName(), rowPks, targetContainer, user, true); + if (errors.hasErrors()) + throw errors; + + if (hasAttachmentProperties) + { + fileAttachmentsMovedCount += moveAttachments(user, sourceContainer, targetContainer, batch, errors); + if (errors.hasErrors()) + throw errors; + } + + queryAuditEventsMovedCount += QueryService.get().moveAuditEvents(targetContainer, rowPks, ListQuerySchema.NAME, _list.getName()); + listAuditEventsMovedCount += listAuditProvider.moveEvents(targetContainer, batch.stream().map(ListRecord::entityId).toList()); + + // Detailed audit events per row + if (AuditBehaviorType.DETAILED == listTable.getEffectiveAuditBehavior(auditBehavior)) + listAuditEventsCreatedCount += addDetailedMoveAuditEvents(user, sourceContainer, targetContainer, batch); + + // After trigger per batch + listTable.fireBatchTrigger(sourceContainer, user, TableInfo.TriggerType.MOVE, false, errors, extraContext); + if (errors.hasErrors()) + throw errors; + } + + // Create a summary audit event for the source container + if (isAuditEnabled) + { + String comment = String.format("Moved %s to %s", StringUtilsLabKey.pluralize(numRecords, "row"), targetContainer.getPath()); + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(sourceContainer, comment, _list); + event.setUserComment(auditUserComment); + listAuditEvents.add(event); + } + + // Create a summary audit event for the target container + if (isAuditEnabled) + { + String comment = String.format("Moved %s from %s", StringUtilsLabKey.pluralize(numRecords, "row"), sourceContainer.getPath()); + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, comment, _list); + event.setUserComment(auditUserComment); + listAuditEvents.add(event); + } + } + + if (!listAuditEvents.isEmpty()) + { + AuditLogService.get().addEvents(user, listAuditEvents, true); + listAuditEventsCreatedCount += listAuditEvents.size(); + } + + tx.addCommitTask(() -> ListManager.get().indexList(_list), DbScope.CommitTaskOption.POSTCOMMIT); + + tx.commit(); + + SimpleMetricsService.get().increment(ExperimentService.MODULE_NAME, "moveEntities", "list"); + } + + return moveRowsCounts(fileAttachmentsMovedCount, listAuditEventsCreatedCount, listAuditEventsMovedCount, listRecordsCount, queryAuditEventsMovedCount); + } + + private Map moveRowsCounts(int fileAttachmentsMovedCount, int listAuditEventsCreated, int listAuditEventsMoved, int listRecords, int queryAuditEventsMoved) + { + return Map.of( + "fileAttachmentsMoved", fileAttachmentsMovedCount, + "listAuditEventsCreated", listAuditEventsCreated, + "listAuditEventsMoved", listAuditEventsMoved, + "listRecords", listRecords, + "queryAuditEventsMoved", queryAuditEventsMoved + ); + } + + private Map> getListRowsForMoveRows( + Container container, + User user, + Container targetContainer, + List> rows, + BatchValidationException errors + ) throws QueryUpdateServiceException + { + if (rows.isEmpty()) + return Collections.emptyMap(); + + String keyName = _list.getKeyName(); + List keys = new ArrayList<>(); + for (var row : rows) + { + Object key = getField(row, keyName); + if (key == null) + { + errors.addRowError(new ValidationException("Key field value required for moving list rows.")); + return Collections.emptyMap(); + } + + keys.add(getKeyFilterValue(key)); + } + + SimpleFilter filter = new SimpleFilter(); + FieldKey fieldKey = FieldKey.fromParts(keyName); + filter.addInClause(fieldKey, keys); + filter.addCondition(FieldKey.fromParts("Container"), targetContainer.getId(), CompareType.NEQ); + + // Request all rows without a container filter so that rows are more easily resolved across the list scope. + // Read permissions are subsequently checked upon loading a row. + TableInfo table = _list.getTable(user, container, ContainerFilter.getUnsafeEverythingFilter()); + if (table == null) + throw new QueryUpdateServiceException(String.format("Failed to resolve table for list %s in %s", _list.getName(), container.getPath())); + + Map> containerRows = new HashMap<>(); + try (var result = new TableSelector(table, PageFlowUtil.set(keyName, "Container", "EntityId"), filter, null).getResults()) + { + while (result.next()) + { + GUID containerId = new GUID(result.getString("Container")); + if (!containerRows.containsKey(containerId)) + { + var c = ContainerManager.getForId(containerId); + if (c == null) + throw new QueryUpdateServiceException(String.format("Failed to resolve container for row in list %s in %s.", _list.getName(), container.getPath())); + else if (!c.hasPermission(user, ReadPermission.class)) + throw new UnauthorizedException("You do not have permission to read list rows in all source containers."); + } + + containerRows.computeIfAbsent(containerId, k -> new ArrayList<>()); + containerRows.get(containerId).add(new ListRecord(result.getObject(fieldKey), result.getString("EntityId"))); + } + } + catch (SQLException e) + { + throw new RuntimeSQLException(e); + } + + return containerRows; + } + + private int moveAttachments(User user, Container sourceContainer, Container targetContainer, List records, BatchValidationException errors) + { + List parents = new ArrayList<>(); + for (ListRecord record : records) + parents.add(new ListItemAttachmentParent(record.entityId, sourceContainer)); + + int count = 0; + try + { + count = AttachmentService.get().moveAttachments(targetContainer, parents, user); + } + catch (IOException e) + { + errors.addRowError(new ValidationException("Failed to move attachments when moving list rows. Error: " + e.getMessage())); + } + + return count; + } + + private int addDetailedMoveAuditEvents(User user, Container sourceContainer, Container targetContainer, List records) + { + List auditEvents = new ArrayList<>(records.size()); + String keyName = _list.getKeyName(); + String sourcePath = sourceContainer.getPath(); + String targetPath = targetContainer.getPath(); + + for (ListRecord record : records) + { + ListAuditProvider.ListAuditEvent event = new ListAuditProvider.ListAuditEvent(targetContainer, "An existing list record was moved", _list); + event.setListItemEntityId(record.entityId); + event.setOldRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", sourcePath, keyName, record.key.toString()))); + event.setNewRecordMap(AbstractAuditTypeProvider.encodeForDataMap(CaseInsensitiveHashMap.of("Folder", targetPath, keyName, record.key.toString()))); + auditEvents.add(event); + } + + AuditLogService.get().addEvents(user, auditEvents, true); + + return auditEvents.size(); + } + + @Override + protected Map deleteRow(User user, Container container, Map oldRowMap) throws InvalidKeyException, QueryUpdateServiceException, SQLException + { + if (!_list.isVisible(user)) + throw new UnauthorizedException("You do not have permission to delete data from this table."); + + Map result = super.deleteRow(getListUser(user, container), container, oldRowMap); + + if (null != result) + { + String entityId = (String) result.get(ID); + + if (null != entityId) + { + ListManager mgr = ListManager.get(); + String deletedRecord = mgr.formatAuditItem(_list, user, result); + + // Audit + mgr.addAuditEvent(_list, user, container, "An existing list record was deleted", entityId, deletedRecord, null); + + // Remove attachments + if (hasAttachmentProperties()) + AttachmentService.get().deleteAttachments(new ListItemAttachmentParent(entityId, container)); + + // Clean up Search indexer + if (!result.isEmpty()) + mgr.deleteItemIndex(_list, entityId); + } + } + + return result; + } + + + // Deletes attachments & discussions, and removes list documents from full-text search index. + public void deleteRelatedListData(final User user, final Container container) + { + // Unindex all item docs and the entire list doc + ListManager.get().deleteIndexedList(_list); + + // Delete attachments and discussions associated with a list in batches of 1,000 + new TableSelector(getDbTable(), Collections.singleton("entityId"), SimpleFilter.createContainerFilter(container), null).forEachBatch(String.class, 1000, new ForEachBatchBlock<>() + { + @Override + public boolean accept(String entityId) + { + return null != entityId; + } + + @Override + public void exec(List entityIds) + { + // delete the related list data for this block + deleteRelatedListData(user, container, entityIds); + } + }); + } + + // delete the related list data for this block of entityIds + private void deleteRelatedListData(User user, Container container, List entityIds) + { + // Build up set of entityIds and AttachmentParents + List attachmentParents = new ArrayList<>(); + + // Delete Attachments + if (hasAttachmentProperties()) + { + for (String entityId : entityIds) + { + attachmentParents.add(new ListItemAttachmentParent(entityId, container)); + } + AttachmentService.get().deleteAttachments(attachmentParents); + } + } + + @Override + protected int truncateRows(User user, Container container) throws QueryUpdateServiceException, SQLException + { + int result; + try (DbScope.Transaction transaction = getDbTable().getSchema().getScope().ensureTransaction()) + { + deleteRelatedListData(user, container); + result = super.truncateRows(getListUser(user, container), container); + transaction.addCommitTask(() -> ListManager.get().addAuditEvent(_list, user, "Deleted " + result + " rows from list."), DbScope.CommitTaskOption.POSTCOMMIT); + transaction.commit(); + } + + return result; + } + + @Nullable + public SimpleFilter getKeyFilter(Map map) throws InvalidKeyException + { + String keyName = _list.getKeyName(); + Object key = getField(map, keyName); + + if (null == key) + { + // Auto-increment lists might not provide a key so allow them to pass through + if (ListDefinition.KeyType.AutoIncrementInteger.equals(_list.getKeyType())) + return null; + throw new InvalidKeyException("No " + keyName + " provided for list \"" + _list.getName() + "\""); + } + + return new SimpleFilter(FieldKey.fromParts(keyName), getKeyFilterValue(key)); + } + + @NotNull + private Object getKeyFilterValue(@NotNull Object key) + { + ListDefinition.KeyType type = _list.getKeyType(); + + // Check the type of the list to ensure proper casting of the key type + if (ListDefinition.KeyType.Integer.equals(type) || ListDefinition.KeyType.AutoIncrementInteger.equals(type)) + return isIntegral(key) ? key : Integer.valueOf(key.toString()); + + return key.toString(); + } + + @Nullable + private Object getField(Map map, String key) + { + /* TODO: this is very strange, we have a TableInfo we should be using its ColumnInfo objects to figure out aliases, we don't need to guess */ + Object value = map.get(key); + + if (null == value) + value = map.get(key + "_"); + + if (null == value) + value = map.get(getDbTable().getSqlDialect().legalNameFromName(key)); + + return value; + } + + /** + * Delegate class to generate an AttachmentParent + */ + public static class ListItemAttachmentParentFactory implements AttachmentParentFactory + { + @Override + public AttachmentParent generateAttachmentParent(String entityId, Container c) + { + return new ListItemAttachmentParent(entityId, c); + } + } + + /** + * Get Domain from list definition, unless null then get from super + */ + @Override + protected Domain getDomain() + { + return _list != null? + _list.getDomain() : + super.getDomain(); + } +} diff --git a/list/src/org/labkey/list/view/ListQueryView.java b/list/src/org/labkey/list/view/ListQueryView.java index 574c07bf275..5e01aaa726d 100644 --- a/list/src/org/labkey/list/view/ListQueryView.java +++ b/list/src/org/labkey/list/view/ListQueryView.java @@ -1,87 +1,87 @@ -/* - * Copyright (c) 2009-2019 LabKey Corporation - * - * 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 - * - * http://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 org.labkey.list.view; - -import org.labkey.api.data.ActionButton; -import org.labkey.api.data.ButtonBar; -import org.labkey.api.data.ContainerFilter; -import org.labkey.api.exp.list.ListDefinition; -import org.labkey.api.lists.permissions.DesignListPermission; -import org.labkey.api.query.QuerySettings; -import org.labkey.api.query.QueryView; -import org.labkey.api.util.URLHelper; -import org.labkey.api.view.ActionURL; -import org.labkey.api.view.DataView; -import org.labkey.list.model.ListQuerySchema; -import org.springframework.validation.BindException; - -public class ListQueryView extends QueryView -{ - private final ListDefinition _list; - - public ListQueryView(ListDefinition def, ListQuerySchema schema, QuerySettings settings, BindException errors) - { - super(schema, settings, errors); - _list = def; - init(); - } - - public ListQueryView(ListQueryForm form, BindException errors) - { - super(form, errors); - _list = form.getList(); - init(); - } - - protected void init() - { - setShowExportButtons(_list.getAllowExport()); - setShowUpdateColumn(true); - setAllowableContainerFilterTypes( - ContainerFilter.Type.Current, - ContainerFilter.Type.CurrentAndSubfoldersPlusShared, - ContainerFilter.Type.CurrentPlusProjectAndShared, - ContainerFilter.Type.AllFolders - ); - } - - @Override - protected boolean canDelete() - { - return super.canDelete() && _list.getAllowDelete(); - } - - @Override - protected void populateButtonBar(DataView view, ButtonBar bar) - { - super.populateButtonBar(view, bar); - - if (getViewContext().hasPermission(DesignListPermission.class)) - { - ActionURL designURL = getList().urlShowDefinition(); - URLHelper returnUrl = getSettings() != null ? getSettings().getReturnUrlHelper() : null; - designURL.addReturnUrl(returnUrl != null ? returnUrl : getViewContext().getActionURL()); - ActionButton btnUpload = new ActionButton("Design", designURL); - bar.add(btnUpload); - } - } - - public ListDefinition getList() - { - return _list; - } -} +/* + * Copyright (c) 2009-2019 LabKey Corporation + * + * 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 + * + * http://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 org.labkey.list.view; + +import org.labkey.api.data.ActionButton; +import org.labkey.api.data.ButtonBar; +import org.labkey.api.data.ContainerFilter; +import org.labkey.api.exp.list.ListDefinition; +import org.labkey.api.lists.permissions.DesignListPermission; +import org.labkey.api.query.QuerySettings; +import org.labkey.api.query.QueryView; +import org.labkey.api.util.URLHelper; +import org.labkey.api.view.ActionURL; +import org.labkey.api.view.DataView; +import org.labkey.list.model.ListQuerySchema; +import org.springframework.validation.BindException; + +public class ListQueryView extends QueryView +{ + private final ListDefinition _list; + + public ListQueryView(ListDefinition def, ListQuerySchema schema, QuerySettings settings, BindException errors) + { + super(schema, settings, errors); + _list = def; + init(); + } + + public ListQueryView(ListQueryForm form, BindException errors) + { + super(form, errors); + _list = form.getList(); + init(); + } + + protected void init() + { + setShowExportButtons(_list.getAllowExport()); + setShowUpdateColumn(true); + setAllowableContainerFilterTypes( + ContainerFilter.Type.Current, + ContainerFilter.Type.CurrentAndSubfoldersPlusShared, + ContainerFilter.Type.CurrentPlusProjectAndShared, + ContainerFilter.Type.AllFolders + ); + } + + @Override + protected boolean canDelete() + { + return super.canDelete() && _list.getAllowDelete(); + } + + @Override + protected void populateButtonBar(DataView view, ButtonBar bar) + { + super.populateButtonBar(view, bar); + + if (getViewContext().hasPermission(DesignListPermission.class)) + { + ActionURL designURL = getList().urlShowDefinition(); + URLHelper returnUrl = getSettings() != null ? getSettings().getReturnUrlHelper() : null; + designURL.addReturnUrl(returnUrl != null ? returnUrl : getViewContext().getActionURL()); + ActionButton btnUpload = new ActionButton("Design", designURL); + bar.add(btnUpload); + } + } + + public ListDefinition getList() + { + return _list; + } +} From ccd92fcc47cd45dcc2eccb99cf20d1ac961fdaef Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 3 Mar 2026 18:03:55 -0800 Subject: [PATCH 3/4] code review changes --- .../list/controllers/ListController.java | 32 ++++++++++++------- .../labkey/list/model/ListDefinitionImpl.java | 6 ++-- .../org/labkey/list/view/truncateListData.jsp | 2 +- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/list/src/org/labkey/list/controllers/ListController.java b/list/src/org/labkey/list/controllers/ListController.java index c6dfd777348..2a5b90e4e8f 100644 --- a/list/src/org/labkey/list/controllers/ListController.java +++ b/list/src/org/labkey/list/controllers/ListController.java @@ -51,6 +51,7 @@ import org.labkey.api.data.ContainerFilter; import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DataRegionSelection; +import org.labkey.api.data.DbScope; import org.labkey.api.data.SQLFragment; import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.TableInfo; @@ -116,6 +117,7 @@ import org.labkey.list.model.ListDomainKindProperties; import org.labkey.list.model.ListManager; import org.labkey.list.model.ListManagerSchema; +import org.labkey.list.model.ListSchema; import org.labkey.list.model.ListWriter; import org.labkey.list.view.ListDefinitionForm; import org.labkey.list.view.ListItemAttachmentParent; @@ -488,23 +490,29 @@ public ModelAndView getConfirmView(ListDeletionForm form, BindException errors) public boolean handlePost(ListDeletionForm form, BindException errors) { Container containerDataToDelete = getContainer(); - for (Pair pair : form.getListContainerMap()) + try (DbScope.Transaction transaction = ListSchema.getInstance().getSchema().getScope().ensureTransaction()) { - Container listDefContainer = pair.second; - ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first); - if (null != listDef) + for (Pair pair : form.getListContainerMap()) { - try - { - TableInfo table = listDef.getTable(getUser(), listDefContainer); - if (table != null && table.getUpdateService() != null) - table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null); - } - catch (Exception e) + Container listDefContainer = pair.second; + ListDefinition listDef = ListService.get().getList(listDefContainer, pair.first); + if (null != listDef) { - errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage()); + try + { + TableInfo table = listDef.getTable(getUser(), listDefContainer); + if (table != null && table.getUpdateService() != null) + table.getUpdateService().truncateRows(getUser(), containerDataToDelete, null, null); + } + catch (Exception e) + { + errors.reject(ERROR_MSG, "Error deleting data from list '" + listDef.getName() + "': " + e.getMessage()); + return false; + } } } + + transaction.commit(); } return !errors.hasErrors(); diff --git a/list/src/org/labkey/list/model/ListDefinitionImpl.java b/list/src/org/labkey/list/model/ListDefinitionImpl.java index 72ec6dbcc66..d70f9652601 100644 --- a/list/src/org/labkey/list/model/ListDefinitionImpl.java +++ b/list/src/org/labkey/list/model/ListDefinitionImpl.java @@ -522,7 +522,7 @@ private ListItem getListItem(SimpleFilter filter, User user, Container c) return impl; } - public Container getListItemContainerForDownload(String entityId, User user, Class permissionClass) + public @Nullable Container getListItemContainerForDownload(String entityId, User user, Class permissionClass) { Container c = getContainer(); SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("EntityId"), entityId); @@ -541,7 +541,9 @@ public Container getListItemContainerForDownload(String entityId, User user, Cla } catch (IllegalStateException e) { - /* more than one row matches */ + // More than one row matches the specified EntityId; log for diagnosis and return null as before + LOG.warn("Multiple list items match EntityId '{}' when resolving download container. List: '{}', Container: '{}'. Returning null.", + entityId, getName(), getContainer().getPath(), e); } if (row == null) diff --git a/list/src/org/labkey/list/view/truncateListData.jsp b/list/src/org/labkey/list/view/truncateListData.jsp index 95f1532ea18..fae430c126f 100644 --- a/list/src/org/labkey/list/view/truncateListData.jsp +++ b/list/src/org/labkey/list/view/truncateListData.jsp @@ -61,7 +61,7 @@ long count = (table != null) ? new TableSelector(table).getRowCount() : 0; %>
  • - <%= simpleLink(listDef.getName(), listDef.urlFor(ListController.GridAction.class, listDef.getContainer())) %> + <%= simpleLink(listDef.getName(), listDef.urlFor(ListController.GridAction.class, currentContainer)) %> — <%= count %> row<%= h(count != 1 ? "s" : "") %> in <%= h(currentPath) %>
  • <% } %> From 1d263f35d018a57e896af19175805d53810bf570 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 5 Mar 2026 09:06:03 -0800 Subject: [PATCH 4/4] fix test --- .../labkey/test/tests/study/TruncationTest.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/study/test/src/org/labkey/test/tests/study/TruncationTest.java b/study/test/src/org/labkey/test/tests/study/TruncationTest.java index 988f3be2b10..917b8e32e83 100644 --- a/study/test/src/org/labkey/test/tests/study/TruncationTest.java +++ b/study/test/src/org/labkey/test/tests/study/TruncationTest.java @@ -79,12 +79,17 @@ private void initTest() public void testTruncateList() { goToProjectHome(); - clickAndWait(Locator.linkWithText(LIST_NAME)); - click(Locator.linkContainingText("Delete All Rows")); - waitAndClick(Ext4Helper.Locators.ext4Button("Yes")); - waitForText("2 rows deleted"); - waitAndClickAndWait(Ext4Helper.Locators.ext4Button("OK")); - waitForText("No data to show."); + var listsPage = goToManageLists(); + var grid = listsPage.getGrid(); + grid.uncheckAllOnPage(); + grid.selectLists(List.of(LIST_NAME)); + grid.clickHeaderMenu("Delete", true, "Delete All Data from List"); + + // Verify confirmation page + assertTextPresent("Are you sure you want to delete all data"); + assertElementPresent(Locator.linkWithText(LIST_NAME)); + assertTextPresent("2 rows"); + clickButton("Confirm Delete All Data"); } @Test