automatic audits

This commit is contained in:
2023-02-15 16:06:06 -06:00
parent 3071c63857
commit 8924657fc1
22 changed files with 1148 additions and 27 deletions

View File

@ -77,9 +77,9 @@ public class AuditAction extends AbstractQActionFunction<AuditInput, AuditOutput
/******************************************************************************* /*******************************************************************************
** Execute to insert 1 audit, with a list of details (child records) ** Execute to insert 1 audit, with a list of detail child records
*******************************************************************************/ *******************************************************************************/
public static void execute(String tableName, Integer recordId, Map<String, Serializable> securityKeyValues, String message, List<String> details) public static void execute(String tableName, Integer recordId, Map<String, Serializable> securityKeyValues, String message, List<QRecord> details)
{ {
new AuditAction().execute(new AuditInput().withAuditSingleInput(new AuditSingleInput() new AuditAction().execute(new AuditInput().withAuditSingleInput(new AuditSingleInput()
.withAuditTableName(tableName) .withAuditTableName(tableName)
@ -105,7 +105,7 @@ public class AuditAction extends AbstractQActionFunction<AuditInput, AuditOutput
/******************************************************************************* /*******************************************************************************
** Add 1 auditSingleInput to an AuditInput object - with a list of details (child records). ** Add 1 auditSingleInput to an AuditInput object - with a list of details (child records).
*******************************************************************************/ *******************************************************************************/
public static AuditInput appendToInput(AuditInput auditInput, String tableName, Integer recordId, Map<String, Serializable> securityKeyValues, String message, List<String> details) public static AuditInput appendToInput(AuditInput auditInput, String tableName, Integer recordId, Map<String, Serializable> securityKeyValues, String message, List<QRecord> details)
{ {
if(auditInput == null) if(auditInput == null)
{ {
@ -198,7 +198,7 @@ public class AuditAction extends AbstractQActionFunction<AuditInput, AuditOutput
////////////////////////////////////////// //////////////////////////////////////////
// now look for children (auditDetails) // // now look for children (auditDetails) //
////////////////////////////////////////// //////////////////////////////////////////
int i = 0; int i = 0;
List<QRecord> auditDetailRecords = new ArrayList<>(); List<QRecord> auditDetailRecords = new ArrayList<>();
for(AuditSingleInput auditSingleInput : CollectionUtils.nonNullList(input.getAuditSingleInputList())) for(AuditSingleInput auditSingleInput : CollectionUtils.nonNullList(input.getAuditSingleInputList()))
{ {
@ -209,12 +209,9 @@ public class AuditAction extends AbstractQActionFunction<AuditInput, AuditOutput
continue; continue;
} }
for(String detail : CollectionUtils.nonNullList(auditSingleInput.getDetails())) for(QRecord detail : CollectionUtils.nonNullList(auditSingleInput.getDetails()))
{ {
auditDetailRecords.add(new QRecord() auditDetailRecords.add(detail.withValue("auditId", auditId));
.withValue("auditId", auditId)
.withValue("message", detail)
);
} }
} }

View File

@ -0,0 +1,381 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.actions.audits;
import java.io.Serializable;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction;
import com.kingsrook.qqq.backend.core.actions.values.QPossibleValueTranslator;
import com.kingsrook.qqq.backend.core.actions.values.QValueFormatter;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
import com.kingsrook.qqq.backend.core.model.actions.audits.AuditInput;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Audit for a standard DML (Data Manipulation Language) activity - e.g.,
** insert, edit, or delete.
*******************************************************************************/
public class DMLAuditAction extends AbstractQActionFunction<DMLAuditInput, DMLAuditOutput>
{
private static final QLogger LOG = QLogger.getLogger(DMLAuditAction.class);
/*******************************************************************************
**
*******************************************************************************/
@Override
public DMLAuditOutput execute(DMLAuditInput input) throws QException
{
DMLAuditOutput output = new DMLAuditOutput();
AbstractTableActionInput tableActionInput = input.getTableActionInput();
List<QRecord> recordList = input.getRecordList();
List<QRecord> oldRecordList = input.getOldRecordList();
QTableMetaData table = tableActionInput.getTable();
long start = System.currentTimeMillis();
DMLType dmlType = getDMLType(tableActionInput);
try
{
AuditLevel auditLevel = getAuditLevel(tableActionInput);
if(auditLevel == null || auditLevel.equals(AuditLevel.NONE))
{
/////////////////////////////////////////////
// return with noop for null or level NONE //
/////////////////////////////////////////////
return (output);
}
String contextSuffix = "";
Optional<AbstractActionInput> actionInput = QContext.getFirstActionInStack();
if(actionInput.isPresent() && actionInput.get() instanceof RunProcessInput runProcessInput)
{
String processName = runProcessInput.getProcessName();
QProcessMetaData process = QContext.getQInstance().getProcess(processName);
if(process != null)
{
contextSuffix = " during process: " + process.getLabel();
}
}
AuditInput auditInput = new AuditInput();
if(auditLevel.equals(AuditLevel.RECORD) || (auditLevel.equals(AuditLevel.FIELD) && !dmlType.supportsFields))
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// make many simple audits (no details) for RECORD level //
// or for FIELD level, but on a DML type that doesn't support field-level details (e.g., DELETE or OTHER) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
for(QRecord record : recordList)
{
AuditAction.appendToInput(auditInput, table.getName(), record.getValueInteger(table.getPrimaryKeyField()), getRecordSecurityKeyValues(table, record), "Record was " + dmlType.pastTenseVerb + contextSuffix);
}
}
else if(auditLevel.equals(AuditLevel.FIELD))
{
Map<Serializable, QRecord> oldRecordMap = buildOldRecordMap(table, oldRecordList);
///////////////////////////////////////////////////////////////////
// do many audits, all with field level details, for FIELD level //
///////////////////////////////////////////////////////////////////
QPossibleValueTranslator qPossibleValueTranslator = new QPossibleValueTranslator(QContext.getQInstance(), QContext.getQSession());
qPossibleValueTranslator.translatePossibleValuesInRecords(table, CollectionUtils.mergeLists(recordList, oldRecordList));
//////////////////////////////////////////
// sort the field names by their labels //
//////////////////////////////////////////
List<String> sortedFieldNames = table.getFields().keySet().stream()
.sorted(Comparator.comparing(fieldName -> table.getFields().get(fieldName).getLabel()))
.toList();
QFieldMetaData primaryKeyField = table.getField(table.getPrimaryKeyField());
//////////////////////////////////////////////
// build single audit input for each record //
//////////////////////////////////////////////
for(QRecord record : recordList)
{
QRecord oldRecord = oldRecordMap.get(ValueUtils.getValueAsFieldType(primaryKeyField.getType(), record.getValue(primaryKeyField.getName())));
List<QRecord> details = new ArrayList<>();
for(String fieldName : sortedFieldNames)
{
if(!record.getValues().containsKey(fieldName))
{
////////////////////////////////////////////////////////////////////////////////////////////////
// if the stored record doesn't have this field name, then don't audit anything about it //
// this is to deal with our Patch style updates not looking like every field was cleared out. //
////////////////////////////////////////////////////////////////////////////////////////////////
continue;
}
if(fieldName.equals("modifyDate") || fieldName.equals("createDate") || fieldName.equals("automationStatus"))
{
continue;
}
QFieldMetaData field = table.getField(fieldName);
Serializable value = ValueUtils.getValueAsFieldType(field.getType(), record.getValue(fieldName));
Serializable oldValue = oldRecord == null ? null : ValueUtils.getValueAsFieldType(field.getType(), oldRecord.getValue(fieldName));
QRecord detailRecord = null;
if(oldRecord == null)
{
if(DMLType.INSERT.equals(dmlType) && value == null)
{
continue;
}
String formattedValue = getFormattedValueForAuditDetail(record, fieldName, field, value);
detailRecord = new QRecord().withValue("message", "Set " + field.getLabel() + " to " + formattedValue);
detailRecord.withValue("newValue", formattedValue);
}
else
{
if(!Objects.equals(oldValue, value))
{
String formattedValue = getFormattedValueForAuditDetail(record, fieldName, field, value);
String formattedOldValue = getFormattedValueForAuditDetail(oldRecord, fieldName, field, oldValue);
if(oldValue == null)
{
detailRecord = new QRecord().withValue("message", "Set " + field.getLabel() + " to " + formatFormattedValueForDetailMessage(field, formattedValue));
detailRecord.withValue("newValue", formattedValue);
}
else if(value == null)
{
detailRecord = new QRecord().withValue("message", "Removed " + formatFormattedValueForDetailMessage(field, formattedOldValue) + " from " + field.getLabel());
detailRecord.withValue("oldValue", formattedOldValue);
}
else
{
detailRecord = new QRecord().withValue("message", "Changed " + field.getLabel() + " from " + formatFormattedValueForDetailMessage(field, formattedOldValue) + " to " + formatFormattedValueForDetailMessage(field, formattedValue));
detailRecord.withValue("oldValue", formattedOldValue);
detailRecord.withValue("newValue", formattedValue);
}
}
}
if(detailRecord != null)
{
detailRecord.withValue("fieldName", fieldName);
details.add(detailRecord);
}
}
if(details.isEmpty())
{
details.add(new QRecord().withValue("message", "No fields values were changed."));
}
AuditAction.appendToInput(auditInput, table.getName(), record.getValueInteger(table.getPrimaryKeyField()), getRecordSecurityKeyValues(table, record), "Record was " + dmlType.pastTenseVerb + contextSuffix, details);
}
}
// new AuditAction().executeAsync(auditInput); // todo async??? maybe get that from rules???
new AuditAction().execute(auditInput);
long end = System.currentTimeMillis();
LOG.debug("Audit performance", logPair("auditLevel", String.valueOf(auditLevel)), logPair("recordCount", recordList.size()), logPair("millis", (end - start)));
}
catch(Exception e)
{
LOG.error("Error performing DML audit", e, logPair("type", String.valueOf(dmlType)), logPair("table", table.getName()));
}
return (output);
}
/*******************************************************************************
**
*******************************************************************************/
private static String getFormattedValueForAuditDetail(QRecord record, String fieldName, QFieldMetaData field, Serializable value)
{
String formattedValue = null;
if(value != null)
{
if(field.getType().equals(QFieldType.DATE_TIME) && value instanceof Instant instant)
{
formattedValue = QValueFormatter.formatDateTimeWithZone(instant.atZone(ZoneId.of(Objects.requireNonNullElse(QContext.getQInstance().getDefaultTimeZoneId(), "UTC"))));
}
else if(record.getDisplayValue(fieldName) != null)
{
formattedValue = record.getDisplayValue(fieldName);
}
else
{
formattedValue = QValueFormatter.formatValue(field, value);
}
}
return formattedValue;
}
/*******************************************************************************
**
*******************************************************************************/
private static String formatFormattedValueForDetailMessage(QFieldMetaData field, String formattedValue)
{
if(formattedValue == null || "null".equals(formattedValue))
{
formattedValue = "--";
}
else
{
if(QFieldType.STRING.equals(field.getType()) || field.getPossibleValueSourceName() != null)
{
formattedValue = '"' + formattedValue + '"';
}
}
return (formattedValue);
}
/*******************************************************************************
**
*******************************************************************************/
private Map<Serializable, QRecord> buildOldRecordMap(QTableMetaData table, List<QRecord> oldRecordList)
{
Map<Serializable, QRecord> rs = new HashMap<>();
for(QRecord record : CollectionUtils.nonNullList(oldRecordList))
{
rs.put(record.getValue(table.getPrimaryKeyField()), record);
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
private DMLType getDMLType(AbstractTableActionInput tableActionInput)
{
if(tableActionInput instanceof InsertInput)
{
return DMLType.INSERT;
}
else if(tableActionInput instanceof UpdateInput)
{
return DMLType.UPDATE;
}
else if(tableActionInput instanceof DeleteInput)
{
return DMLType.DELETE;
}
else
{
return DMLType.OTHER;
}
}
/*******************************************************************************
**
*******************************************************************************/
private static Map<String, Serializable> getRecordSecurityKeyValues(QTableMetaData table, QRecord record)
{
Map<String, Serializable> securityKeyValues = new HashMap<>();
for(RecordSecurityLock recordSecurityLock : CollectionUtils.nonNullList(table.getRecordSecurityLocks()))
{
securityKeyValues.put(recordSecurityLock.getSecurityKeyType(), record == null ? null : record.getValue(recordSecurityLock.getFieldName()));
}
return securityKeyValues;
}
/*******************************************************************************
**
*******************************************************************************/
public static AuditLevel getAuditLevel(AbstractTableActionInput tableActionInput)
{
QTableMetaData table = tableActionInput.getTable();
if(table.getAuditRules() == null)
{
return (AuditLevel.NONE);
}
return (table.getAuditRules().getAuditLevel());
}
/*******************************************************************************
**
*******************************************************************************/
private enum DMLType
{
INSERT("Inserted", true),
UPDATE("Edited", true),
DELETE("Deleted", false),
OTHER("Processed", false);
private final String pastTenseVerb;
private final boolean supportsFields;
/*******************************************************************************
**
*******************************************************************************/
DMLType(String pastTenseVerb, boolean supportsFields)
{
this.pastTenseVerb = pastTenseVerb;
this.supportsFields = supportsFields;
}
}
}

View File

@ -63,6 +63,18 @@ public class NoCodeWidgetVelocityUtils
/*******************************************************************************
**
*******************************************************************************/
public String helpIcon()
{
return ("""
<span class="material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit" style="color: blue; position: relative; top: 6px;" aria-hidden="true">help_outline</span>
""");
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -26,13 +26,17 @@ import java.io.Serializable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.ActionHelper;
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
import com.kingsrook.qqq.backend.core.actions.interfaces.DeleteInterface; import com.kingsrook.qqq.backend.core.actions.interfaces.DeleteInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
@ -81,10 +85,44 @@ public class DeleteAction
} }
} }
DeleteOutput deleteResult = deleteInterface.execute(deleteInput); /*******************************************************************************
**
*******************************************************************************/
List<QRecord> recordListForAudit = getRecordListForAuditIfNeeded(deleteInput);
DeleteOutput deleteOutput = deleteInterface.execute(deleteInput);
// todo post-customization - can do whatever w/ the result if you want // todo post-customization - can do whatever w/ the result if you want
return deleteResult; new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(deleteInput).withRecordList(recordListForAudit));
return deleteOutput;
}
/*******************************************************************************
**
*******************************************************************************/
private static List<QRecord> getRecordListForAuditIfNeeded(DeleteInput deleteInput) throws QException
{
List<QRecord> recordListForAudit = null;
AuditLevel auditLevel = DMLAuditAction.getAuditLevel(deleteInput);
if(AuditLevel.RECORD.equals(auditLevel) || AuditLevel.FIELD.equals(auditLevel))
{
List<Serializable> primaryKeyList = deleteInput.getPrimaryKeys();
if(CollectionUtils.nullSafeIsEmpty(deleteInput.getPrimaryKeys()) && deleteInput.getQueryFilter() != null)
{
primaryKeyList = getPrimaryKeysFromQueryFilter(deleteInput);
}
if(primaryKeyList != null)
{
recordListForAudit = primaryKeyList.stream().map(pk -> new QRecord().withValue(deleteInput.getTable().getPrimaryKeyField(), pk)).toList();
}
}
return (recordListForAudit);
} }

View File

@ -32,6 +32,7 @@ import java.util.Set;
import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction; import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction;
import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.ActionHelper;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus;
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater; import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater;
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer;
@ -41,6 +42,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.helpers.UniqueKeyHelper;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecord;
@ -84,6 +86,8 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
InsertOutput insertOutput = qModule.getInsertInterface().execute(insertInput); InsertOutput insertOutput = qModule.getInsertInterface().execute(insertInput);
// todo post-customization - can do whatever w/ the result if you want // todo post-customization - can do whatever w/ the result if you want
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(insertInput).withRecordList(insertOutput.getRecords()));
if(postInsertCustomizer.isPresent()) if(postInsertCustomizer.isPresent())
{ {
postInsertCustomizer.get().setInsertInput(insertInput); postInsertCustomizer.get().setInsertInput(insertInput);
@ -94,7 +98,6 @@ public class InsertAction extends AbstractQActionFunction<InsertInput, InsertOut
} }
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -22,15 +22,28 @@
package com.kingsrook.qqq.backend.core.actions.tables; package com.kingsrook.qqq.backend.core.actions.tables;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.ActionHelper; import com.kingsrook.qqq.backend.core.actions.ActionHelper;
import com.kingsrook.qqq.backend.core.actions.audits.DMLAuditAction;
import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus; import com.kingsrook.qqq.backend.core.actions.automation.AutomationStatus;
import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater; import com.kingsrook.qqq.backend.core.actions.automation.RecordAutomationStatusUpdater;
import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier; import com.kingsrook.qqq.backend.core.actions.values.ValueBehaviorApplier;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface; import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/******************************************************************************* /*******************************************************************************
@ -39,6 +52,10 @@ import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleInterface;
*******************************************************************************/ *******************************************************************************/
public class UpdateAction public class UpdateAction
{ {
private static final QLogger LOG = QLogger.getLogger(UpdateAction.class);
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -50,16 +67,54 @@ public class UpdateAction
ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), updateInput.getTable(), updateInput.getRecords()); ValueBehaviorApplier.applyFieldBehaviors(updateInput.getInstance(), updateInput.getTable(), updateInput.getRecords());
// todo - need to handle records with errors coming out of here... // todo - need to handle records with errors coming out of here...
List<QRecord> oldRecordList = getOldRecordListForAuditIfNeeded(updateInput);
QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher();
QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(updateInput.getBackend()); QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(updateInput.getBackend());
// todo pre-customization - just get to modify the request? // todo pre-customization - just get to modify the request?
UpdateOutput updateResult = qModule.getUpdateInterface().execute(updateInput); UpdateOutput updateResult = qModule.getUpdateInterface().execute(updateInput);
// todo post-customization - can do whatever w/ the result if you want // todo post-customization - can do whatever w/ the result if you want
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(updateInput).withRecordList(updateResult.getRecords()).withOldRecordList(oldRecordList));
return updateResult; return updateResult;
} }
/*******************************************************************************
**
*******************************************************************************/
private static List<QRecord> getOldRecordListForAuditIfNeeded(UpdateInput updateInput)
{
try
{
AuditLevel auditLevel = DMLAuditAction.getAuditLevel(updateInput);
List<QRecord> oldRecordList = null;
if(AuditLevel.FIELD.equals(auditLevel))
{
String primaryKeyField = updateInput.getTable().getPrimaryKeyField();
List<Serializable> pkeysBeingUpdated = updateInput.getRecords().stream().map(r -> r.getValue(primaryKeyField)).toList();
QueryInput queryInput = new QueryInput();
queryInput.setTableName(updateInput.getTableName());
queryInput.setFilter(new QQueryFilter(new QFilterCriteria(primaryKeyField, QCriteriaOperator.IN, pkeysBeingUpdated)));
// todo - need a limit? what if too many??
QueryOutput queryOutput = new QueryAction().execute(queryInput);
oldRecordList = queryOutput.getRecords();
}
return oldRecordList;
}
catch(Exception e)
{
LOG.warn("Error getting old record list for audit", e, logPair("table", updateInput.getTableName()));
return (null);
}
}
/******************************************************************************* /*******************************************************************************
** If the table being updated uses an automation-status field, populate it now. ** If the table being updated uses an automation-status field, populate it now.
*******************************************************************************/ *******************************************************************************/

View File

@ -47,10 +47,10 @@ public class QValueFormatter
{ {
private static final QLogger LOG = QLogger.getLogger(QValueFormatter.class); private static final QLogger LOG = QLogger.getLogger(QValueFormatter.class);
private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm a"); private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a");
private static DateTimeFormatter dateTimeWithZoneFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm a z"); private static DateTimeFormatter dateTimeWithZoneFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a z");
private static DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static DateTimeFormatter localTimeFormatter = DateTimeFormatter.ofPattern("h:mm a"); private static DateTimeFormatter localTimeFormatter = DateTimeFormatter.ofPattern("h:mm:ss a");

View File

@ -22,6 +22,7 @@
package com.kingsrook.qqq.backend.core.context; package com.kingsrook.qqq.backend.core.context;
import java.util.Optional;
import java.util.Stack; import java.util.Stack;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException;
@ -243,4 +244,19 @@ public class QContext
{ {
qBackendTransactionThreadLocal.remove(); qBackendTransactionThreadLocal.remove();
} }
/*******************************************************************************
**
*******************************************************************************/
public static Optional<AbstractActionInput> getFirstActionInStack()
{
if(actionStackThreadLocal.get() == null || actionStackThreadLocal.get().isEmpty())
{
return (Optional.empty());
}
return (Optional.of(actionStackThreadLocal.get().get(0)));
}
} }

View File

@ -197,6 +197,20 @@ public class QInstanceEnricher
} }
enrichPermissionRules(table); enrichPermissionRules(table);
enrichAuditRules(table);
}
/*******************************************************************************
**
*******************************************************************************/
private void enrichAuditRules(QTableMetaData table)
{
if(table.getAuditRules() == null && qInstance.getDefaultAuditRules() != null)
{
table.setAuditRules(qInstance.getDefaultAuditRules());
}
} }

View File

@ -47,7 +47,7 @@ public class AuditSingleInput
private Map<String, Serializable> securityKeyValues; private Map<String, Serializable> securityKeyValues;
private List<String> details; private List<QRecord> details;
@ -254,10 +254,12 @@ public class AuditSingleInput
return (this); return (this);
} }
/******************************************************************************* /*******************************************************************************
** Getter for details ** Getter for details
*******************************************************************************/ *******************************************************************************/
public List<String> getDetails() public List<QRecord> getDetails()
{ {
return (this.details); return (this.details);
} }
@ -267,7 +269,7 @@ public class AuditSingleInput
/******************************************************************************* /*******************************************************************************
** Setter for details ** Setter for details
*******************************************************************************/ *******************************************************************************/
public void setDetails(List<String> details) public void setDetails(List<QRecord> details)
{ {
this.details = details; this.details = details;
} }
@ -277,21 +279,24 @@ public class AuditSingleInput
/******************************************************************************* /*******************************************************************************
** Fluent setter for details ** Fluent setter for details
*******************************************************************************/ *******************************************************************************/
public AuditSingleInput withDetails(List<String> details) public AuditSingleInput withDetails(List<QRecord> details)
{ {
this.details = details; this.details = details;
return (this); return (this);
} }
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
public void addDetail(String detail) public void addDetail(String message)
{ {
if(this.details == null) if(this.details == null)
{ {
this.details = new ArrayList<>(); this.details = new ArrayList<>();
} }
QRecord detail = new QRecord().withValue("message", message);
this.details.add(detail); this.details.add(detail);
} }

View File

@ -0,0 +1,134 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.model.actions.audits;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Input object for the DML audit action.
*******************************************************************************/
public class DMLAuditInput extends AbstractActionInput implements Serializable
{
private List<QRecord> recordList;
private List<QRecord> oldRecordList;
private AbstractTableActionInput tableActionInput;
/*******************************************************************************
** Getter for recordList
*******************************************************************************/
public List<QRecord> getRecordList()
{
return (this.recordList);
}
/*******************************************************************************
** Setter for recordList
*******************************************************************************/
public void setRecordList(List<QRecord> recordList)
{
this.recordList = recordList;
}
/*******************************************************************************
** Fluent setter for recordList
*******************************************************************************/
public DMLAuditInput withRecordList(List<QRecord> recordList)
{
this.recordList = recordList;
return (this);
}
/*******************************************************************************
** Getter for tableActionInput
*******************************************************************************/
public AbstractTableActionInput getTableActionInput()
{
return (this.tableActionInput);
}
/*******************************************************************************
** Setter for tableActionInput
*******************************************************************************/
public void setTableActionInput(AbstractTableActionInput tableActionInput)
{
this.tableActionInput = tableActionInput;
}
/*******************************************************************************
** Fluent setter for tableActionInput
*******************************************************************************/
public DMLAuditInput withTableActionInput(AbstractTableActionInput tableActionInput)
{
this.tableActionInput = tableActionInput;
return (this);
}
/*******************************************************************************
** Getter for oldRecordList
*******************************************************************************/
public List<QRecord> getOldRecordList()
{
return (this.oldRecordList);
}
/*******************************************************************************
** Setter for oldRecordList
*******************************************************************************/
public void setOldRecordList(List<QRecord> oldRecordList)
{
this.oldRecordList = oldRecordList;
}
/*******************************************************************************
** Fluent setter for oldRecordList
*******************************************************************************/
public DMLAuditInput withOldRecordList(List<QRecord> oldRecordList)
{
this.oldRecordList = oldRecordList;
return (this);
}
}

View File

@ -0,0 +1,35 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.model.actions.audits;
import java.io.Serializable;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionOutput;
/*******************************************************************************
** Output object for the DML audit action.
*******************************************************************************/
public class DMLAuditOutput extends AbstractActionOutput implements Serializable
{
}

View File

@ -27,6 +27,8 @@ import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
@ -165,6 +167,7 @@ public class AuditsMetaDataProvider
return new QTableMetaData() return new QTableMetaData()
.withName(TABLE_NAME_AUDIT_TABLE) .withName(TABLE_NAME_AUDIT_TABLE)
.withBackendName(backendName) .withBackendName(backendName)
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormat("%s") .withRecordLabelFormat("%s")
.withRecordLabelFields("label") .withRecordLabelFields("label")
.withPrimaryKeyField("id") .withPrimaryKeyField("id")
@ -186,6 +189,7 @@ public class AuditsMetaDataProvider
return new QTableMetaData() return new QTableMetaData()
.withName(TABLE_NAME_AUDIT_USER) .withName(TABLE_NAME_AUDIT_USER)
.withBackendName(backendName) .withBackendName(backendName)
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormat("%s") .withRecordLabelFormat("%s")
.withRecordLabelFields("name") .withRecordLabelFields("name")
.withPrimaryKeyField("id") .withPrimaryKeyField("id")
@ -206,6 +210,7 @@ public class AuditsMetaDataProvider
return new QTableMetaData() return new QTableMetaData()
.withName(TABLE_NAME_AUDIT) .withName(TABLE_NAME_AUDIT)
.withBackendName(backendName) .withBackendName(backendName)
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormat("%s %s") .withRecordLabelFormat("%s %s")
.withRecordLabelFields("auditTableId", "recordId") .withRecordLabelFields("auditTableId", "recordId")
.withPrimaryKeyField("id") .withPrimaryKeyField("id")
@ -227,12 +232,16 @@ public class AuditsMetaDataProvider
return new QTableMetaData() return new QTableMetaData()
.withName(TABLE_NAME_AUDIT_DETAIL) .withName(TABLE_NAME_AUDIT_DETAIL)
.withBackendName(backendName) .withBackendName(backendName)
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
.withRecordLabelFormat("%s") .withRecordLabelFormat("%s")
.withRecordLabelFields("id") .withRecordLabelFields("id")
.withPrimaryKeyField("id") .withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)) .withField(new QFieldMetaData("id", QFieldType.INTEGER))
.withField(new QFieldMetaData("auditId", QFieldType.INTEGER).withPossibleValueSourceName(TABLE_NAME_AUDIT)) .withField(new QFieldMetaData("auditId", QFieldType.INTEGER).withPossibleValueSourceName(TABLE_NAME_AUDIT))
.withField(new QFieldMetaData("message", QFieldType.STRING).withMaxLength(250).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS)); .withField(new QFieldMetaData("message", QFieldType.STRING).withMaxLength(250).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS))
.withField(new QFieldMetaData("fieldName", QFieldType.STRING).withMaxLength(100).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS))
.withField(new QFieldMetaData("oldValue", QFieldType.STRING).withMaxLength(250).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS))
.withField(new QFieldMetaData("newValue", QFieldType.STRING).withMaxLength(250).withBehavior(ValueTooLongBehavior.TRUNCATE_ELLIPSIS));
} }
} }

View File

@ -36,6 +36,7 @@ import com.kingsrook.qqq.backend.core.instances.QInstanceValidationKey;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput; import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.automation.QAutomationProviderMetaData; import com.kingsrook.qqq.backend.core.model.metadata.automation.QAutomationProviderMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData;
@ -93,6 +94,7 @@ public class QInstance
private String defaultTimeZoneId = "UTC"; private String defaultTimeZoneId = "UTC";
private QPermissionRules defaultPermissionRules = QPermissionRules.defaultInstance(); private QPermissionRules defaultPermissionRules = QPermissionRules.defaultInstance();
private QAuditRules defaultAuditRules = QAuditRules.defaultInstanceLevelNone();
// todo - lock down the object (no more changes allowed) after it's been validated? // todo - lock down the object (no more changes allowed) after it's been validated?
@ -1042,4 +1044,35 @@ public class QInstance
return (rs); return (rs);
} }
/*******************************************************************************
** Getter for defaultAuditRules
*******************************************************************************/
public QAuditRules getDefaultAuditRules()
{
return (this.defaultAuditRules);
}
/*******************************************************************************
** Setter for defaultAuditRules
*******************************************************************************/
public void setDefaultAuditRules(QAuditRules defaultAuditRules)
{
this.defaultAuditRules = defaultAuditRules;
}
/*******************************************************************************
** Fluent setter for defaultAuditRules
*******************************************************************************/
public QInstance withDefaultAuditRules(QAuditRules defaultAuditRules)
{
this.defaultAuditRules = defaultAuditRules;
return (this);
}
} }

View File

@ -0,0 +1,33 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.model.metadata.audits;
/*******************************************************************************
**
*******************************************************************************/
public enum AuditLevel
{
NONE,
RECORD,
FIELD
}

View File

@ -0,0 +1,74 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.model.metadata.audits;
/*******************************************************************************
**
*******************************************************************************/
public class QAuditRules
{
private AuditLevel auditLevel;
/*******************************************************************************
**
*******************************************************************************/
public static QAuditRules defaultInstanceLevelNone()
{
return (new QAuditRules()
.withAuditLevel(AuditLevel.NONE));
}
/*******************************************************************************
** Getter for auditLevel
*******************************************************************************/
public AuditLevel getAuditLevel()
{
return (this.auditLevel);
}
/*******************************************************************************
** Setter for auditLevel
*******************************************************************************/
public void setAuditLevel(AuditLevel auditLevel)
{
this.auditLevel = auditLevel;
}
/*******************************************************************************
** Fluent setter for auditLevel
*******************************************************************************/
public QAuditRules withAuditLevel(AuditLevel auditLevel)
{
this.auditLevel = auditLevel;
return (this);
}
}

View File

@ -37,6 +37,7 @@ public class HtmlWrapper
public static final HtmlWrapper BIG_CENTERED = new HtmlWrapper("<div style='font-size: 2rem; font-weight: 400; line-height: 1.625; text-align: center; padding-bottom: 8px;'>", "</div>"); public static final HtmlWrapper BIG_CENTERED = new HtmlWrapper("<div style='font-size: 2rem; font-weight: 400; line-height: 1.625; text-align: center; padding-bottom: 8px;'>", "</div>");
public static final HtmlWrapper INDENT_1 = new HtmlWrapper("<div style='padding-left: 1rem;'>", "</div>"); public static final HtmlWrapper INDENT_1 = new HtmlWrapper("<div style='padding-left: 1rem;'>", "</div>");
public static final HtmlWrapper INDENT_2 = new HtmlWrapper("<div style='padding-left: 2rem;'>", "</div>"); public static final HtmlWrapper INDENT_2 = new HtmlWrapper("<div style='padding-left: 2rem;'>", "</div>");
public static final HtmlWrapper FLOAT_RIGHT = new HtmlWrapper("<div style='float: right'>", "</div>");
public static final HtmlWrapper RULE_ABOVE = new HtmlWrapper(""" public static final HtmlWrapper RULE_ABOVE = new HtmlWrapper("""
<hr style="opacity: 0.25; height: 0.0625rem; border-width: 0; margin-bottom: 1rem; background-image: linear-gradient(to right, rgba(52, 71, 103, 0), rgba(52, 71, 103, 0.4), rgba(52, 71, 103, 0));" /> <hr style="opacity: 0.25; height: 0.0625rem; border-width: 0; margin-bottom: 1rem; background-image: linear-gradient(to right, rgba(52, 71, 103, 0), rgba(52, 71, 103, 0.4), rgba(52, 71, 103, 0));" />
""", ""); """, "");

View File

@ -37,6 +37,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntityField; import com.kingsrook.qqq.backend.core.model.data.QRecordEntityField;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData;
@ -74,6 +75,7 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData
private List<RecordSecurityLock> recordSecurityLocks; private List<RecordSecurityLock> recordSecurityLocks;
private QPermissionRules permissionRules; private QPermissionRules permissionRules;
private QAuditRules auditRules;
private QTableBackendDetails backendDetails; private QTableBackendDetails backendDetails;
private QTableAutomationDetails automationDetails; private QTableAutomationDetails automationDetails;
@ -1142,4 +1144,35 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData
return (this); return (this);
} }
/*******************************************************************************
** Getter for auditRules
*******************************************************************************/
public QAuditRules getAuditRules()
{
return (this.auditRules);
}
/*******************************************************************************
** Setter for auditRules
*******************************************************************************/
public void setAuditRules(QAuditRules auditRules)
{
this.auditRules = auditRules;
}
/*******************************************************************************
** Fluent setter for auditRules
*******************************************************************************/
public QTableMetaData withAuditRules(QAuditRules auditRules)
{
this.auditRules = auditRules;
return (this);
}
} }

View File

@ -165,9 +165,9 @@ class AuditActionTest extends BaseTest
Integer recordId2 = 1702; Integer recordId2 = 1702;
Integer recordId3 = 1703; Integer recordId3 = 1703;
AuditInput auditInput = new AuditInput(); AuditInput auditInput = new AuditInput();
AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_PERSON_MEMORY, recordId1, Map.of(), "Test Audit", List.of("Detail1", "Detail2")); AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_PERSON_MEMORY, recordId1, Map.of(), "Test Audit", List.of(new QRecord().withValue("message", "Detail1"), new QRecord().withValue("message", "Detail2")));
AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_ORDER, recordId2, Map.of(TestUtils.SECURITY_KEY_TYPE_STORE, 47), "Test Another Audit", null); AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_ORDER, recordId2, Map.of(TestUtils.SECURITY_KEY_TYPE_STORE, 47), "Test Another Audit", null);
AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_PERSON_MEMORY, recordId3, Map.of(TestUtils.SECURITY_KEY_TYPE_STORE, 42), "Audit 3", List.of("Detail3")); AuditAction.appendToInput(auditInput, TestUtils.TABLE_NAME_PERSON_MEMORY, recordId3, Map.of(TestUtils.SECURITY_KEY_TYPE_STORE, 42), "Audit 3", List.of(new QRecord().withValue("message", "Detail3")));
new AuditAction().execute(auditInput); new AuditAction().execute(auditInput);
///////////////////////////////////// /////////////////////////////////////

View File

@ -0,0 +1,178 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.backend.core.actions.audits;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.audits.DMLAuditInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Unit test for DMLAuditAction
*******************************************************************************/
class DMLAuditActionTest extends BaseTest
{
/*******************************************************************************
**
*******************************************************************************/
@Test
void test() throws QException
{
QInstance qInstance = QContext.getQInstance();
new AuditsMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
InsertInput insertInput = new InsertInput();
insertInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
List<QRecord> recordList = List.of(new QRecord().withValue("id", 1).withValue("firstName", "Darin").withValue("noOfShoes", 5).withValue("favoriteShapeId", null).withValue("createDate", Instant.now()).withValue("modifyDate", Instant.now()));
List<QRecord> oldRecordList = List.of(new QRecord().withValue("id", 1).withValue("firstName", "Tim").withValue("noOfShoes", null).withValue("favoriteShapeId", 1).withValue("lastName", "Simpson"));
////////////////////////////////////////////////////////
// set audit rules null - confirm no audits are built //
////////////////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(null);
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(deleteInput).withRecordList(recordList));
List<QRecord> auditList = TestUtils.queryTable("audit");
assertTrue(auditList.isEmpty());
}
////////////////////////////////////////////////////////
// set audit level NONE - confirm no audits are built //
////////////////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE));
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(deleteInput).withRecordList(recordList));
List<QRecord> auditList = TestUtils.queryTable("audit");
assertTrue(auditList.isEmpty());
}
/////////////////////////////////////////////////////////////
// set audit level RECORD - confirm only header, no detail //
/////////////////////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD));
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(insertInput).withRecordList(recordList));
List<QRecord> auditList = TestUtils.queryTable("audit");
assertEquals(1, auditList.size());
assertEquals("Record was Inserted", auditList.get(0).getValueString("message"));
List<QRecord> auditDetailList = TestUtils.queryTable("auditDetail");
assertTrue(auditDetailList.isEmpty());
MemoryRecordStore.getInstance().reset();
}
////////////////////////////////////////////////////////
// set audit level FIELD - confirm header, and detail //
////////////////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(updateInput).withRecordList(recordList));
List<QRecord> auditList = TestUtils.queryTable("audit");
assertEquals(1, auditList.size());
assertEquals("Record was Edited", auditList.get(0).getValueString("message"));
List<QRecord> auditDetailList = TestUtils.queryTable("auditDetail");
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// since we didn't provide old-records, there should be a detail for every field in the updated record - OTHER THAN createDate and modifyDate //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
assertEquals(recordList.get(0).getValues().size() - 2, auditDetailList.size());
assertTrue(auditDetailList.stream().allMatch(r -> r.getValueString("message").matches("Set.*to.*")));
assertTrue(auditDetailList.stream().allMatch(r -> r.getValueString("fieldName") != null));
assertTrue(auditDetailList.stream().allMatch(r -> r.getValueString("oldValue") == null));
assertTrue(auditDetailList.stream().anyMatch(r -> r.getValueString("newValue") != null));
MemoryRecordStore.getInstance().reset();
}
//////////////////////////////////////////////
// this time supply old-records to the edit //
//////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_SHAPE).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE));
TestUtils.insertDefaultShapes(qInstance);
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(updateInput).withOldRecordList(oldRecordList).withRecordList(recordList));
List<QRecord> auditList = TestUtils.queryTable("audit");
assertEquals(1, auditList.size());
assertEquals("Record was Edited", auditList.get(0).getValueString("message"));
List<QRecord> auditDetailList = TestUtils.queryTable("auditDetail");
assertEquals(3, auditDetailList.size());
assertEquals("Removed \"Triangle\" from Favorite Shape", auditDetailList.get(0).getValueString("message"));
assertEquals("favoriteShapeId", auditDetailList.get(0).getValueString("fieldName"));
assertEquals("Triangle", auditDetailList.get(0).getValueString("oldValue"));
assertNull(auditDetailList.get(0).getValueString("newValue"));
assertEquals("Changed First Name from \"Tim\" to \"Darin\"", auditDetailList.get(1).getValueString("message"));
assertEquals("firstName", auditDetailList.get(1).getValueString("fieldName"));
assertEquals("Tim", auditDetailList.get(1).getValueString("oldValue"));
assertEquals("Darin", auditDetailList.get(1).getValueString("newValue"));
assertEquals("Set No Of Shoes to 5", auditDetailList.get(2).getValueString("message"));
assertEquals("noOfShoes", auditDetailList.get(2).getValueString("fieldName"));
assertNull(auditDetailList.get(2).getValueString("oldValue"));
assertEquals("5", auditDetailList.get(2).getValueString("newValue"));
MemoryRecordStore.getInstance().reset();
}
/////////////////////////////////////////////////
// confirm we don't log null fields on inserts //
/////////////////////////////////////////////////
{
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
new DMLAuditAction().execute(new DMLAuditInput().withTableActionInput(insertInput).withRecordList(recordList));
List<QRecord> auditDetailList = TestUtils.queryTable("auditDetail");
assertFalse(auditDetailList.isEmpty());
assertTrue(auditDetailList.stream().noneMatch(r -> r.getValueString("message").contains("Favorite Shape")));
MemoryRecordStore.getInstance().reset();
}
}
}

View File

@ -24,11 +24,21 @@ package com.kingsrook.qqq.backend.core.actions.tables;
import java.util.List; import java.util.List;
import com.kingsrook.qqq.backend.core.BaseTest; import com.kingsrook.qqq.backend.core.BaseTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.TestUtils;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
@ -79,4 +89,64 @@ class DeleteActionTest extends BaseTest
}); });
} }
/*******************************************************************************
**
*******************************************************************************/
@Test
void testAuditsByPrimaryKey() throws QException
{
QInstance qInstance = QContext.getQInstance();
InsertInput insertInput = new InsertInput();
insertInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
insertInput.setRecords(List.of(
new QRecord().withValue("id", 1),
new QRecord().withValue("id", 2)));
new InsertAction().execute(insertInput);
new AuditsMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null);
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD));
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
deleteInput.setPrimaryKeys(List.of(1, 2));
new DeleteAction().execute(deleteInput);
List<QRecord> audits = TestUtils.queryTable("audit");
assertEquals(2, audits.size());
assertTrue(audits.stream().allMatch(r -> r.getValueString("message").equals("Record was Deleted")));
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testAuditsByFilter() throws QException
{
QInstance qInstance = QContext.getQInstance();
InsertInput insertInput = new InsertInput();
insertInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
insertInput.setRecords(List.of(
new QRecord().withValue("id", 1),
new QRecord().withValue("id", 2)));
new InsertAction().execute(insertInput);
new AuditsMetaDataProvider().defineAll(qInstance, TestUtils.MEMORY_BACKEND_NAME, null);
qInstance.getTable(TestUtils.TABLE_NAME_PERSON_MEMORY).setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.RECORD));
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(TestUtils.TABLE_NAME_PERSON_MEMORY);
deleteInput.setQueryFilter(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, 1, 2)));
new DeleteAction().execute(deleteInput);
List<QRecord> audits = TestUtils.queryTable("audit");
assertEquals(2, audits.size());
assertTrue(audits.stream().allMatch(r -> r.getValueString("message").equals("Record was Deleted")));
}
} }

View File

@ -83,8 +83,8 @@ class QValueFormatterTest extends BaseTest
assertEquals("No", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.BOOLEAN), false)); assertEquals("No", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.BOOLEAN), false));
assertNull(QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), null)); assertNull(QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), null));
assertEquals("5:00 AM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(5, 0))); assertEquals("5:00:00 AM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(5, 0)));
assertEquals("5:00 PM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(17, 0))); assertEquals("5:00:47 PM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(17, 0, 47)));
////////////////////////////////////////////////// //////////////////////////////////////////////////
// this one flows through the exceptional cases // // this one flows through the exceptional cases //
@ -196,8 +196,8 @@ class QValueFormatterTest extends BaseTest
void testFormatDates() void testFormatDates()
{ {
assertEquals("2023-02-01", QValueFormatter.formatDate(LocalDate.of(2023, Month.FEBRUARY, 1))); assertEquals("2023-02-01", QValueFormatter.formatDate(LocalDate.of(2023, Month.FEBRUARY, 1)));
assertEquals("2023-02-01 07:15 PM", QValueFormatter.formatDateTime(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15))); assertEquals("2023-02-01 07:15:00 PM", QValueFormatter.formatDateTime(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15, 0)));
assertEquals("2023-02-01 07:15 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15), ZoneId.of("US/Central")))); assertEquals("2023-02-01 07:15:47 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15, 47), ZoneId.of("US/Central"))));
} }
} }