diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/AuditAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/AuditAction.java index 5266a9a9..7d6a77c0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/AuditAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/audits/AuditAction.java @@ -77,9 +77,9 @@ public class AuditAction extends AbstractQActionFunction securityKeyValues, String message, List details) + public static void execute(String tableName, Integer recordId, Map securityKeyValues, String message, List details) { new AuditAction().execute(new AuditInput().withAuditSingleInput(new AuditSingleInput() .withAuditTableName(tableName) @@ -105,7 +105,7 @@ public class AuditAction extends AbstractQActionFunction securityKeyValues, String message, List details) + public static AuditInput appendToInput(AuditInput auditInput, String tableName, Integer recordId, Map securityKeyValues, String message, List details) { if(auditInput == null) { @@ -198,7 +198,7 @@ public class AuditAction extends AbstractQActionFunction auditDetailRecords = new ArrayList<>(); for(AuditSingleInput auditSingleInput : CollectionUtils.nonNullList(input.getAuditSingleInputList())) { @@ -209,12 +209,9 @@ public class AuditAction extends AbstractQActionFunction. + */ + +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 +{ + 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 recordList = input.getRecordList(); + List 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 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 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 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 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 buildOldRecordMap(QTableMetaData table, List oldRecordList) + { + Map 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 getRecordSecurityKeyValues(QTableMetaData table, QRecord record) + { + Map 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; + } + } +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/NoCodeWidgetVelocityUtils.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/NoCodeWidgetVelocityUtils.java index 2c37f31d..f79d3213 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/NoCodeWidgetVelocityUtils.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/dashboard/widgets/NoCodeWidgetVelocityUtils.java @@ -63,6 +63,18 @@ public class NoCodeWidgetVelocityUtils + /******************************************************************************* + ** + *******************************************************************************/ + public String helpIcon() + { + return (""" + + """); + } + + + /******************************************************************************* ** *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java index 680989b9..71e67e7a 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteAction.java @@ -26,13 +26,17 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; 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.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.delete.DeleteInput; 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.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.QBackendModuleInterface; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; @@ -81,10 +85,44 @@ public class DeleteAction } } - DeleteOutput deleteResult = deleteInterface.execute(deleteInput); + /******************************************************************************* + ** + *******************************************************************************/ + List recordListForAudit = getRecordListForAuditIfNeeded(deleteInput); + + DeleteOutput deleteOutput = deleteInterface.execute(deleteInput); // 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 getRecordListForAuditIfNeeded(DeleteInput deleteInput) throws QException + { + List recordListForAudit = null; + + AuditLevel auditLevel = DMLAuditAction.getAuditLevel(deleteInput); + if(AuditLevel.RECORD.equals(auditLevel) || AuditLevel.FIELD.equals(auditLevel)) + { + List 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); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java index f9e2b93d..118bedd8 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/tables/InsertAction.java @@ -32,6 +32,7 @@ import java.util.Set; import com.kingsrook.qqq.backend.core.actions.AbstractQActionFunction; import com.kingsrook.qqq.backend.core.actions.ActionHelper; 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.RecordAutomationStatusUpdater; 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.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.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -84,6 +86,8 @@ public class InsertAction extends AbstractQActionFunction oldRecordList = getOldRecordListForAuditIfNeeded(updateInput); + QBackendModuleDispatcher qBackendModuleDispatcher = new QBackendModuleDispatcher(); QBackendModuleInterface qModule = qBackendModuleDispatcher.getQBackendModule(updateInput.getBackend()); // todo pre-customization - just get to modify the request? UpdateOutput updateResult = qModule.getUpdateInterface().execute(updateInput); // 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; } + /******************************************************************************* + ** + *******************************************************************************/ + private static List getOldRecordListForAuditIfNeeded(UpdateInput updateInput) + { + try + { + AuditLevel auditLevel = DMLAuditAction.getAuditLevel(updateInput); + List oldRecordList = null; + if(AuditLevel.FIELD.equals(auditLevel)) + { + String primaryKeyField = updateInput.getTable().getPrimaryKeyField(); + List 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. *******************************************************************************/ diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java index 6304f73f..ddfa92aa 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatter.java @@ -47,10 +47,10 @@ public class QValueFormatter { private static final QLogger LOG = QLogger.getLogger(QValueFormatter.class); - private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm a"); - private static DateTimeFormatter dateTimeWithZoneFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm a z"); + private static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a"); + 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 localTimeFormatter = DateTimeFormatter.ofPattern("h:mm a"); + private static DateTimeFormatter localTimeFormatter = DateTimeFormatter.ofPattern("h:mm:ss a"); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java index 1b0b45e8..4c7bcbba 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/context/QContext.java @@ -22,6 +22,7 @@ package com.kingsrook.qqq.backend.core.context; +import java.util.Optional; import java.util.Stack; import com.kingsrook.qqq.backend.core.actions.QBackendTransaction; import com.kingsrook.qqq.backend.core.exceptions.QInstanceValidationException; @@ -243,4 +244,19 @@ public class QContext { qBackendTransactionThreadLocal.remove(); } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static Optional getFirstActionInStack() + { + if(actionStackThreadLocal.get() == null || actionStackThreadLocal.get().isEmpty()) + { + return (Optional.empty()); + } + + return (Optional.of(actionStackThreadLocal.get().get(0))); + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java index 37d0af53..f82740f0 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/instances/QInstanceEnricher.java @@ -197,6 +197,20 @@ public class QInstanceEnricher } enrichPermissionRules(table); + enrichAuditRules(table); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void enrichAuditRules(QTableMetaData table) + { + if(table.getAuditRules() == null && qInstance.getDefaultAuditRules() != null) + { + table.setAuditRules(qInstance.getDefaultAuditRules()); + } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java index 1adf3582..2711f3d4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/AuditSingleInput.java @@ -47,7 +47,7 @@ public class AuditSingleInput private Map securityKeyValues; - private List details; + private List details; @@ -254,10 +254,12 @@ public class AuditSingleInput return (this); } + + /******************************************************************************* ** Getter for details *******************************************************************************/ - public List getDetails() + public List getDetails() { return (this.details); } @@ -267,7 +269,7 @@ public class AuditSingleInput /******************************************************************************* ** Setter for details *******************************************************************************/ - public void setDetails(List details) + public void setDetails(List details) { this.details = details; } @@ -277,21 +279,24 @@ public class AuditSingleInput /******************************************************************************* ** Fluent setter for details *******************************************************************************/ - public AuditSingleInput withDetails(List details) + public AuditSingleInput withDetails(List details) { this.details = details; return (this); } + + /******************************************************************************* ** *******************************************************************************/ - public void addDetail(String detail) + public void addDetail(String message) { if(this.details == null) { this.details = new ArrayList<>(); } + QRecord detail = new QRecord().withValue("message", message); this.details.add(detail); } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditInput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditInput.java new file mode 100644 index 00000000..095d1c5d --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditInput.java @@ -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 . + */ + +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 recordList; + private List oldRecordList; + private AbstractTableActionInput tableActionInput; + + + + /******************************************************************************* + ** Getter for recordList + *******************************************************************************/ + public List getRecordList() + { + return (this.recordList); + } + + + + /******************************************************************************* + ** Setter for recordList + *******************************************************************************/ + public void setRecordList(List recordList) + { + this.recordList = recordList; + } + + + + /******************************************************************************* + ** Fluent setter for recordList + *******************************************************************************/ + public DMLAuditInput withRecordList(List 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 getOldRecordList() + { + return (this.oldRecordList); + } + + + + /******************************************************************************* + ** Setter for oldRecordList + *******************************************************************************/ + public void setOldRecordList(List oldRecordList) + { + this.oldRecordList = oldRecordList; + } + + + + /******************************************************************************* + ** Fluent setter for oldRecordList + *******************************************************************************/ + public DMLAuditInput withOldRecordList(List oldRecordList) + { + this.oldRecordList = oldRecordList; + return (this); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditOutput.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditOutput.java new file mode 100644 index 00000000..725df8b8 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/actions/audits/DMLAuditOutput.java @@ -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 . + */ + +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 +{ + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/audits/AuditsMetaDataProvider.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/audits/AuditsMetaDataProvider.java index a525b7ec..6f796932 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/audits/AuditsMetaDataProvider.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/audits/AuditsMetaDataProvider.java @@ -27,6 +27,8 @@ import java.util.List; import java.util.function.Consumer; 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.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.QFieldType; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; @@ -165,6 +167,7 @@ public class AuditsMetaDataProvider return new QTableMetaData() .withName(TABLE_NAME_AUDIT_TABLE) .withBackendName(backendName) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) .withRecordLabelFormat("%s") .withRecordLabelFields("label") .withPrimaryKeyField("id") @@ -186,6 +189,7 @@ public class AuditsMetaDataProvider return new QTableMetaData() .withName(TABLE_NAME_AUDIT_USER) .withBackendName(backendName) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) .withRecordLabelFormat("%s") .withRecordLabelFields("name") .withPrimaryKeyField("id") @@ -206,6 +210,7 @@ public class AuditsMetaDataProvider return new QTableMetaData() .withName(TABLE_NAME_AUDIT) .withBackendName(backendName) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) .withRecordLabelFormat("%s %s") .withRecordLabelFields("auditTableId", "recordId") .withPrimaryKeyField("id") @@ -227,12 +232,16 @@ public class AuditsMetaDataProvider return new QTableMetaData() .withName(TABLE_NAME_AUDIT_DETAIL) .withBackendName(backendName) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE)) .withRecordLabelFormat("%s") .withRecordLabelFields("id") .withPrimaryKeyField("id") .withField(new QFieldMetaData("id", QFieldType.INTEGER)) .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)); } } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java index b35b7940..7c45cf88 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/QInstance.java @@ -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.metadata.MetaDataInput; 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.automation.QAutomationProviderMetaData; import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; @@ -93,6 +94,7 @@ public class QInstance private String defaultTimeZoneId = "UTC"; private QPermissionRules defaultPermissionRules = QPermissionRules.defaultInstance(); + private QAuditRules defaultAuditRules = QAuditRules.defaultInstanceLevelNone(); // todo - lock down the object (no more changes allowed) after it's been validated? @@ -1042,4 +1044,35 @@ public class QInstance 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); + } + } diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/AuditLevel.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/AuditLevel.java new file mode 100644 index 00000000..e80e2d68 --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/AuditLevel.java @@ -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 . + */ + +package com.kingsrook.qqq.backend.core.model.metadata.audits; + + +/******************************************************************************* + ** + *******************************************************************************/ +public enum AuditLevel +{ + NONE, + RECORD, + FIELD +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/QAuditRules.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/QAuditRules.java new file mode 100644 index 00000000..f7cf4bfa --- /dev/null +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/audits/QAuditRules.java @@ -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 . + */ + +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); + } + +} diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/dashboard/nocode/HtmlWrapper.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/dashboard/nocode/HtmlWrapper.java index aabb008f..729fe2d4 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/dashboard/nocode/HtmlWrapper.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/dashboard/nocode/HtmlWrapper.java @@ -37,6 +37,7 @@ public class HtmlWrapper public static final HtmlWrapper BIG_CENTERED = new HtmlWrapper("
", "
"); public static final HtmlWrapper INDENT_1 = new HtmlWrapper("
", "
"); public static final HtmlWrapper INDENT_2 = new HtmlWrapper("
", "
"); + public static final HtmlWrapper FLOAT_RIGHT = new HtmlWrapper("
", "
"); public static final HtmlWrapper RULE_ABOVE = new HtmlWrapper("""
""", ""); diff --git a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java index e34998c0..c3187ccb 100644 --- a/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java +++ b/qqq-backend-core/src/main/java/com/kingsrook/qqq/backend/core/model/metadata/tables/QTableMetaData.java @@ -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.QRecordEntityField; 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.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppChildMetaData; @@ -74,6 +75,7 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData private List recordSecurityLocks; private QPermissionRules permissionRules; + private QAuditRules auditRules; private QTableBackendDetails backendDetails; private QTableAutomationDetails automationDetails; @@ -1142,4 +1144,35 @@ public class QTableMetaData implements QAppChildMetaData, Serializable, MetaData 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); + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/AuditActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/AuditActionTest.java index 54698ec8..dd8f10d3 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/AuditActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/AuditActionTest.java @@ -165,9 +165,9 @@ class AuditActionTest extends BaseTest Integer recordId2 = 1702; Integer recordId3 = 1703; 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_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); ///////////////////////////////////// diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditActionTest.java new file mode 100644 index 00000000..e46547c1 --- /dev/null +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/audits/DMLAuditActionTest.java @@ -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 . + */ + +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 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 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 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 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 auditList = TestUtils.queryTable("audit"); + assertEquals(1, auditList.size()); + assertEquals("Record was Inserted", auditList.get(0).getValueString("message")); + List 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 auditList = TestUtils.queryTable("audit"); + assertEquals(1, auditList.size()); + assertEquals("Record was Edited", auditList.get(0).getValueString("message")); + List 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 auditList = TestUtils.queryTable("audit"); + assertEquals(1, auditList.size()); + assertEquals("Record was Edited", auditList.get(0).getValueString("message")); + List 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 auditDetailList = TestUtils.queryTable("auditDetail"); + assertFalse(auditDetailList.isEmpty()); + assertTrue(auditDetailList.stream().noneMatch(r -> r.getValueString("message").contains("Favorite Shape"))); + MemoryRecordStore.getInstance().reset(); + } + } + +} \ No newline at end of file diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java index a21ee65c..758471c1 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/tables/DeleteActionTest.java @@ -24,11 +24,21 @@ package com.kingsrook.qqq.backend.core.actions.tables; 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.tables.delete.DeleteInput; 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.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.TestUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; 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 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 audits = TestUtils.queryTable("audit"); + assertEquals(2, audits.size()); + assertTrue(audits.stream().allMatch(r -> r.getValueString("message").equals("Record was Deleted"))); + } + } diff --git a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java index 40877f8a..e6e783ed 100644 --- a/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java +++ b/qqq-backend-core/src/test/java/com/kingsrook/qqq/backend/core/actions/values/QValueFormatterTest.java @@ -83,8 +83,8 @@ class QValueFormatterTest extends BaseTest assertEquals("No", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.BOOLEAN), false)); 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 PM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(17, 0))); + assertEquals("5:00:00 AM", QValueFormatter.formatValue(new QFieldMetaData().withType(QFieldType.TIME), LocalTime.of(5, 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 // @@ -196,8 +196,8 @@ class QValueFormatterTest extends BaseTest void testFormatDates() { 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 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15), ZoneId.of("US/Central")))); + 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:47 PM CST", QValueFormatter.formatDateTimeWithZone(ZonedDateTime.of(LocalDateTime.of(2023, Month.FEBRUARY, 1, 19, 15, 47), ZoneId.of("US/Central")))); } } \ No newline at end of file