From 9753c8321d8facbc8755fc0a1a1796f2befd6ed4 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 07:00:33 +0100 Subject: [PATCH 1/6] Put audit changes in words a person reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change history read like a database dump: one line per stored field, list elements shown by their ids, property names and true/false. - Changes of one list element are one line, named by the element: "Specification attributes › Color family: added (Specification attribute option: Black, Show on product page: Yes, …)"; a removed element is one "removed" line - The element name is taken when the change is recorded (AuditChange .ElementNames), from the element's [AuditName] or Name; a reference names it by the referenced record. Element types without a Name are marked - Field names in words, Yes/No, names without the stored ids - The history popup text is cut to 300 characters and values to 60, with "…"; the full entry stays one click away - The detail page shows the field in words above the stored path; the CSV gets an ElementNames column. Stored paths and values do not change --- src/Core/Grand.Domain/Audit/AuditChange.cs | 7 + .../Grand.Domain/Audit/AuditNameAttribute.cs | 3 +- .../Grand.Domain/Blogs/BlogCategoryPost.cs | 1 + .../Grand.Domain/Catalog/BundleProduct.cs | 1 + .../Catalog/ProductAttributeCombination.cs | 2 + .../Catalog/ProductAttributeMapping.cs | 1 + .../Grand.Domain/Catalog/ProductCategory.cs | 1 + .../Grand.Domain/Catalog/ProductCollection.cs | 1 + .../ProductCombinationWarehouseInventory.cs | 1 + src/Core/Grand.Domain/Catalog/ProductPrice.cs | 1 + .../Catalog/ProductSpecificationAttribute.cs | 1 + .../Catalog/ProductWarehouseInventory.cs | 1 + .../Grand.Domain/Catalog/RelatedProduct.cs | 4 + .../Grand.Domain/Catalog/SimilarProduct.cs | 4 + .../Grand.Domain/Discounts/DiscountRule.cs | 5 +- .../Orders/GiftVoucherUsageHistory.cs | 1 + .../Grand.Domain/Orders/OrderApprovalItem.cs | 1 + src/Core/Grand.Domain/Orders/OrderItem.cs | 1 + .../Grand.Domain/Orders/ShoppingCartItem.cs | 4 + .../Grand.Domain/Shipping/ShipmentItem.cs | 1 + src/Core/Grand.Domain/Stores/DomainHost.cs | 5 +- .../Audit/AuditReferences.cs | 95 ++++++- .../Audit/AuditTracker.cs | 3 + .../Audit/AuditReferencesTests.cs | 54 ++++ .../Controllers/AuditLogControllerTests.cs | 38 ++- .../Models/AuditChangeTextTests.cs | 161 ++++++++++++ .../Areas/Admin/Views/AuditLog/Detail.cshtml | 2 +- .../Controllers/AuditLogController.cs | 33 +-- .../Models/AuditLog/AuditChangeText.cs | 233 ++++++++++++++++++ .../Models/AuditLog/AuditEntryModel.cs | 11 +- .../App_Data/Resources/DefaultLanguage.xml | Bin 1629618 -> 1630022 bytes .../App_Data/Resources/Upgrade/en_250.xml | 6 + 32 files changed, 651 insertions(+), 32 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs create mode 100644 src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs diff --git a/src/Core/Grand.Domain/Audit/AuditChange.cs b/src/Core/Grand.Domain/Audit/AuditChange.cs index de86828043..1609c612f5 100644 --- a/src/Core/Grand.Domain/Audit/AuditChange.cs +++ b/src/Core/Grand.Domain/Audit/AuditChange.cs @@ -10,6 +10,13 @@ public class AuditChange /// public string Path { get; set; } + /// + /// Gets or sets the names of the list elements the path runs through, outermost first (an empty name where an + /// element has none), e.g. "Color family" for ProductSpecificationAttributes[id=…].DisplayOrder; null when + /// the path runs through no named element + /// + public IList ElementNames { get; set; } + /// /// Gets or sets the value before the change (null when masked or when the field was added) /// diff --git a/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs b/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs index f728458a89..88f940cce1 100644 --- a/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs +++ b/src/Core/Grand.Domain/Audit/AuditNameAttribute.cs @@ -2,7 +2,8 @@ namespace Grand.Domain.Audit; /// /// Marks the property that names a record in the audit log (shown in the list, and the only trace of what a -/// deleted record was). Without it, the record's Name property is used. +/// deleted record was), or an element of a record's list. Without it, the Name property is used. On an +/// [AuditReference] property, the name is the referenced record's. /// [AttributeUsage(AttributeTargets.Property)] public sealed class AuditNameAttribute : Attribute; diff --git a/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs b/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs index 4becd91a74..dee13b476d 100644 --- a/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs +++ b/src/Core/Grand.Domain/Blogs/BlogCategoryPost.cs @@ -5,5 +5,6 @@ namespace Grand.Domain.Blogs; public class BlogCategoryPost : SubBaseEntity { [AuditReference(typeof(BlogPost))] + [AuditName] public string BlogPostId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Catalog/BundleProduct.cs b/src/Core/Grand.Domain/Catalog/BundleProduct.cs index 67bcdb4a80..55e1ced65c 100644 --- a/src/Core/Grand.Domain/Catalog/BundleProduct.cs +++ b/src/Core/Grand.Domain/Catalog/BundleProduct.cs @@ -11,6 +11,7 @@ public class BundleProduct : SubBaseEntity /// Gets or sets the product identifier /// [AuditReference(typeof(Product))] + [AuditName] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductAttributeCombination.cs b/src/Core/Grand.Domain/Catalog/ProductAttributeCombination.cs index 5ef747323c..b8660927a1 100644 --- a/src/Core/Grand.Domain/Catalog/ProductAttributeCombination.cs +++ b/src/Core/Grand.Domain/Catalog/ProductAttributeCombination.cs @@ -1,3 +1,4 @@ +using Grand.Domain.Audit; using Grand.Domain.Common; namespace Grand.Domain.Catalog; @@ -38,6 +39,7 @@ public class ProductAttributeCombination : SubBaseEntity, ICloneable /// /// Gets or sets the SKU /// + [AuditName] public string Sku { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs b/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs index 1b2e2aea16..3b573c7cf0 100644 --- a/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs +++ b/src/Core/Grand.Domain/Catalog/ProductAttributeMapping.cs @@ -15,6 +15,7 @@ public class ProductAttributeMapping : SubBaseEntity, ITranslationEntity /// Gets or sets the product attribute identifier /// [AuditReference(typeof(ProductAttribute))] + [AuditName] public string ProductAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCategory.cs b/src/Core/Grand.Domain/Catalog/ProductCategory.cs index acd3b14bab..59074bc31b 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCategory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCategory.cs @@ -11,6 +11,7 @@ public class ProductCategory : SubBaseEntity /// Gets or sets the category identifier /// [AuditReference(typeof(Category))] + [AuditName] public string CategoryId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCollection.cs b/src/Core/Grand.Domain/Catalog/ProductCollection.cs index 6bdaa9446e..0c1fb520b5 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCollection.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCollection.cs @@ -11,6 +11,7 @@ public class ProductCollection : SubBaseEntity /// Gets or sets the collection identifier /// [AuditReference(typeof(Collection))] + [AuditName] public string CollectionId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs b/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs index 8e341df2c9..d74b8c597a 100644 --- a/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductCombinationWarehouseInventory.cs @@ -12,6 +12,7 @@ public class ProductCombinationWarehouseInventory : SubBaseEntity /// Gets or sets the warehouse identifier /// [AuditReference(typeof(Warehouse))] + [AuditName] public string WarehouseId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductPrice.cs b/src/Core/Grand.Domain/Catalog/ProductPrice.cs index 5b8bba819e..8e09ccc38a 100644 --- a/src/Core/Grand.Domain/Catalog/ProductPrice.cs +++ b/src/Core/Grand.Domain/Catalog/ProductPrice.cs @@ -6,6 +6,7 @@ public class ProductPrice : SubBaseEntity { [AuditReference(typeof(Product))] public string ProductId { get; set; } + [AuditName] public string CurrencyCode { get; set; } public double Price { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs b/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs index 195e610c4b..f55ea68d95 100644 --- a/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs +++ b/src/Core/Grand.Domain/Catalog/ProductSpecificationAttribute.cs @@ -16,6 +16,7 @@ public class ProductSpecificationAttribute : SubBaseEntity /// Gets or sets the specification attribute Id /// [AuditReference(typeof(SpecificationAttribute))] + [AuditName] public string SpecificationAttributeId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs b/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs index 206111ccae..fc16e68d47 100644 --- a/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs +++ b/src/Core/Grand.Domain/Catalog/ProductWarehouseInventory.cs @@ -12,6 +12,7 @@ public class ProductWarehouseInventory : SubBaseEntity /// Gets or sets the warehouse identifier /// [AuditReference(typeof(Warehouse))] + [AuditName] public string WarehouseId { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/RelatedProduct.cs b/src/Core/Grand.Domain/Catalog/RelatedProduct.cs index 8e8dbf68fc..055c434477 100644 --- a/src/Core/Grand.Domain/Catalog/RelatedProduct.cs +++ b/src/Core/Grand.Domain/Catalog/RelatedProduct.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -8,6 +10,8 @@ public class RelatedProduct : SubBaseEntity /// /// Gets or sets the second product identifier /// + [AuditReference(typeof(Product))] + [AuditName] public string ProductId2 { get; set; } /// diff --git a/src/Core/Grand.Domain/Catalog/SimilarProduct.cs b/src/Core/Grand.Domain/Catalog/SimilarProduct.cs index 492662342c..5420463507 100644 --- a/src/Core/Grand.Domain/Catalog/SimilarProduct.cs +++ b/src/Core/Grand.Domain/Catalog/SimilarProduct.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Catalog; /// @@ -13,6 +15,8 @@ public class SimilarProduct : SubBaseEntity /// /// Gets or sets the second product identifier /// + [AuditReference(typeof(Product))] + [AuditName] public string ProductId2 { get; set; } /// diff --git a/src/Core/Grand.Domain/Discounts/DiscountRule.cs b/src/Core/Grand.Domain/Discounts/DiscountRule.cs index 286d252ec7..bef94ba800 100644 --- a/src/Core/Grand.Domain/Discounts/DiscountRule.cs +++ b/src/Core/Grand.Domain/Discounts/DiscountRule.cs @@ -1,4 +1,6 @@ -namespace Grand.Domain.Discounts; +using Grand.Domain.Audit; + +namespace Grand.Domain.Discounts; /// /// Represents a discount requirement @@ -8,6 +10,7 @@ public class DiscountRule : SubBaseEntity /// /// Gets or sets the discount requirement rule system name /// + [AuditName] public string DiscountRequirementRuleSystemName { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs b/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs index e53a3b2de2..c85056466d 100644 --- a/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs +++ b/src/Core/Grand.Domain/Orders/GiftVoucherUsageHistory.cs @@ -17,6 +17,7 @@ public class GiftVoucherUsageHistory : SubBaseEntity /// Gets or sets the order identifier /// [AuditReference(typeof(Order))] + [AuditName] public string UsedWithOrderId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs index ebfe3504e8..f23653376e 100644 --- a/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs +++ b/src/Core/Grand.Domain/Orders/OrderApprovalItem.cs @@ -12,6 +12,7 @@ public class OrderApprovalItem : SubBaseEntity /// Gets or sets the product identifier /// [AuditReference(typeof(Product))] + [AuditName] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderItem.cs b/src/Core/Grand.Domain/Orders/OrderItem.cs index 3153fadaa8..8d049604e0 100644 --- a/src/Core/Grand.Domain/Orders/OrderItem.cs +++ b/src/Core/Grand.Domain/Orders/OrderItem.cs @@ -20,6 +20,7 @@ public class OrderItem : SubBaseEntity /// Gets or sets the product identifier /// [AuditReference(typeof(Product))] + [AuditName] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/ShoppingCartItem.cs b/src/Core/Grand.Domain/Orders/ShoppingCartItem.cs index 8758a7014f..b9a25a0ef4 100644 --- a/src/Core/Grand.Domain/Orders/ShoppingCartItem.cs +++ b/src/Core/Grand.Domain/Orders/ShoppingCartItem.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; +using Grand.Domain.Catalog; using Grand.Domain.Common; namespace Grand.Domain.Orders; @@ -25,6 +27,8 @@ public class ShoppingCartItem : SubBaseEntity /// /// Gets or sets the product identifier /// + [AuditReference(typeof(Product))] + [AuditName] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/ShipmentItem.cs b/src/Core/Grand.Domain/Shipping/ShipmentItem.cs index 245bfcf2b0..1e78b4564b 100644 --- a/src/Core/Grand.Domain/Shipping/ShipmentItem.cs +++ b/src/Core/Grand.Domain/Shipping/ShipmentItem.cs @@ -18,6 +18,7 @@ public class ShipmentItem : SubBaseEntity /// Gets or sets the product /// [AuditReference(typeof(Product))] + [AuditName] public string ProductId { get; set; } /// diff --git a/src/Core/Grand.Domain/Stores/DomainHost.cs b/src/Core/Grand.Domain/Stores/DomainHost.cs index 2e1171f052..945523dccf 100644 --- a/src/Core/Grand.Domain/Stores/DomainHost.cs +++ b/src/Core/Grand.Domain/Stores/DomainHost.cs @@ -1,7 +1,10 @@ -namespace Grand.Domain.Stores; +using Grand.Domain.Audit; + +namespace Grand.Domain.Stores; public class DomainHost : SubBaseEntity { + [AuditName] public string HostName { get; set; } public string Url { get; set; } public bool Primary { get; set; } diff --git a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs index b0ef9b6054..d20750df63 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs @@ -16,7 +16,7 @@ public partial class AuditReferences(IAuditRecordReader reader) private static readonly ConcurrentDictionary NameProperties = new(); private static readonly ConcurrentDictionary<(Type, string), PropertyInfo> Properties = new(); - private readonly Dictionary<(Type Target, string List, string Owner, string Id), string> _names = new(); + private readonly Dictionary<(Type Target, string List, string Key, string Owner, string Id), string> _names = new(); [GeneratedRegex(@"\[[^\]]*\]")] private static partial Regex ElementKeys(); @@ -57,8 +57,87 @@ await Describe(property, change.Path, change.OldValue, before), } } - //"ProductSpecificationAttributes[id=1].SpecificationAttributeOptionId" -> the property on the element type - private static PropertyInfo PropertyAt(Type type, string path) + /// + /// Names the list elements each changed path runs through (see ), so a + /// change reads "Color family" instead of an element id + /// + public async Task NameElements(Type entityType, IEnumerable changes, + IDictionary before, IDictionary after) + { + var named = new Dictionary(); + foreach (var change in changes) + { + //a name that cannot be found leaves the element ids, never the change out + try + { + var names = new List(); + var type = entityType; + var path = ""; + foreach (var segment in change.Path.Split('.')) + { + path = path == "" ? segment : $"{path}.{segment}"; + var property = type?.GetProperty(segment.Split('[')[0]); + if (property == null) + break; + + var elementType = ElementType(property.PropertyType); + if (elementType != null && segment.Contains("[id=")) + { + if (!named.TryGetValue(path, out var name)) + named[path] = name = await ElementName(elementType, path, before, after); + names.Add(name ?? ""); + } + + type = elementType ?? property.PropertyType; + } + + if (names.Any(n => n != "")) + change.ElementNames = names; + } + catch (Exception) + { + //keep the path only + } + } + } + + //the element's [AuditName] or Name value; a reference names it by the referenced record + private async Task ElementName(Type elementType, string path, IDictionary before, + IDictionary after) + { + var property = elementType.GetProperty(NameProperty(elementType)); + if (property == null) + return null; + + var field = $"{path}.{property.Name}"; + var snapshot = after != null && after.ContainsKey(field) ? after : before; + if (snapshot == null || !snapshot.TryGetValue(field, out var value) || string.IsNullOrEmpty(value)) + return null; + + var reference = property.GetCustomAttribute(); + if (reference == null || reference.Target.IsEnum) + return value; + + var owner = reference.Owner == null ? null : SiblingValue(snapshot, field, reference.Owner); + return await NameOrNull(reference, owner, value); + } + + /// + /// Whether the values of a path are stored as "name (id)": a reference, or a number of an enum + /// + public static bool HasNamedValues(Type entityType, string path) + { + var property = PropertyAt(entityType, path); + return property != null + && (property.IsDefined(typeof(AuditReferenceAttribute)) + || (Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType).IsEnum); + } + + /// + /// The property a changed path ends at, e.g. "ProductSpecificationAttributes[id=1].SpecificationAttributeOptionId" + /// is SpecificationAttributeOptionId of the element type; null when the path is not a property + /// + public static PropertyInfo PropertyAt(Type type, string path) { //cached by the path without its element keys, so the cache grows with the model, not with the data return Properties.GetOrAdd((type, ElementKeys().Replace(path, "")), key => FindProperty(key.Item1, key.Item2)); @@ -125,14 +204,20 @@ private static string SiblingValue(IDictionary snapshot, string private async Task Label(AuditReferenceAttribute reference, string owner, string id) { - var key = (reference.Target, reference.List, owner, id); + var name = await NameOrNull(reference, owner, id); + return name == null ? id : $"{name} ({id})"; + } + + private async Task NameOrNull(AuditReferenceAttribute reference, string owner, string id) + { + var key = (reference.Target, reference.List, reference.Key, owner, id); if (!_names.TryGetValue(key, out var name)) { name = await NameOf(reference, owner, id); _names[key] = name; } - return string.IsNullOrEmpty(name) ? id : $"{name} ({id})"; + return string.IsNullOrEmpty(name) ? null : name; } //the name in the snapshot of the referenced record, or of the element of its list diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 8ff0970b95..bf4b8716dc 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -234,7 +234,10 @@ private static async Task> BuildEntries( { entry = Build(group.Key, item.Key.Id, item.Value, after); if (entry != null) + { + await references.NameElements(group.Key, entry.Changes, item.Value.Before, after); await references.Describe(group.Key, entry.Changes, item.Value.Before, after); + } } catch (Exception) { diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs index f77fee3416..8d30ee8c4b 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs @@ -190,6 +190,60 @@ public async Task LongNames_CutToTheLimitAndMarked() Assert.IsTrue(change.Truncated); } + private async Task> ElementNames(string path, IDictionary before, + IDictionary after) + { + var change = new AuditChange { Path = path }; + await _references.NameElements(typeof(Product), [change], before, after); + return change.ElementNames; + } + + [TestMethod] + public async Task Element_NamedByTheRecordItRefersTo() + { + _records[(typeof(SpecificationAttribute), "s1")] = new Dictionary { ["Name"] = "Color family" }; + var product = new Dictionary { ["ProductSpecificationAttributes[id=p1].SpecificationAttributeId"] = "s1" }; + + var names = await ElementNames("ProductSpecificationAttributes[id=p1].DisplayOrder", product, product); + + CollectionAssert.AreEqual(new[] { "Color family" }, names.ToArray()); + } + + [TestMethod] + public async Task NestedElements_EachNamed() + { + _records[(typeof(ProductAttribute), "pa1")] = new Dictionary { ["Name"] = "Color" }; + var product = new Dictionary { + ["ProductAttributeMappings[id=m].ProductAttributeId"] = "pa1", + ["ProductAttributeMappings[id=m].ProductAttributeValues[id=v].Name"] = "Red" + }; + + var names = await ElementNames("ProductAttributeMappings[id=m].ProductAttributeValues[id=v].PriceAdjustment", + product, product); + + CollectionAssert.AreEqual(new[] { "Color", "Red" }, names.ToArray()); + } + + [TestMethod] + public async Task RemovedElement_NamedFromBefore() + { + _records[(typeof(Category), "c1")] = new Dictionary { ["Name"] = "Lamps" }; + var before = new Dictionary { ["ProductCategories[id=x].CategoryId"] = "c1" }; + + var names = await ElementNames("ProductCategories[id=x].DisplayOrder", before, new Dictionary()); + + CollectionAssert.AreEqual(new[] { "Lamps" }, names.ToArray()); + } + + [TestMethod] + public async Task ElementWithoutName_NoNames() + { + var product = new Dictionary { ["TierPrices[id=t].Quantity"] = "5" }; + + Assert.IsNull(await ElementNames("TierPrices[id=t].Quantity", null, product)); + Assert.IsNull(await ElementNames("Name", null, product)); + } + [TestMethod] public async Task UnmarkedOrMissing_KeepsTheStoredValue() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs index a3d7525f4c..947e4a6fc4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/AuditLogControllerTests.cs @@ -139,7 +139,7 @@ public async Task ExportCsv_WritesHeaderAndOneRowPerChange() var lines = ResponseText().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); Assert.AreEqual(4, lines.Length); StringAssert.StartsWith(lines[0], "CreatedOnUtc,Kind,Source,ActorEmail"); - StringAssert.Contains(lines[1], ",Price,1,2,"); + StringAssert.Contains(lines[1], ",Price,,1,2,"); } [TestMethod] @@ -190,6 +190,42 @@ public async Task ExportCsv_HasEntityStoreIdColumn() Assert.AreEqual("s2", cells[Array.IndexOf(columns, "EntityStoreId")]); } + [TestMethod] + public async Task ExportCsv_HasElementNamesColumn() + { + _auditService.Setup(s => s.StreamEntries(It.IsAny(), It.IsAny())) + .Returns(Stream(new AuditEntry { + EntityType = "Product", + Changes = [new AuditChange { + Path = "ProductAttributeMappings[id=m].ProductAttributeValues[id=v].Name", NewValue = "Red", + ElementNames = ["Color", "Red"] + }] + })); + + await _controller.ExportCsv(new AuditLogListModel()); + + var lines = ResponseText().Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + var columns = lines[0].Split(','); + var cells = lines[1].Split(','); + Assert.AreEqual("Color › Red", cells[Array.IndexOf(columns, "ElementNames")]); + } + + [TestMethod] + public async Task Detail_FieldInWordsNextToThePath() + { + var change = new AuditChange { + Path = "ProductSpecificationAttributes[id=a].DisplayOrder", OldValue = "0", NewValue = "1", + ElementNames = ["Color family"] + }; + _auditService.Setup(s => s.GetById("e1")) + .ReturnsAsync(new AuditEntry { EntityType = "Product", Changes = [change] }); + + var model = (AuditEntryModel)((ViewResult)await _controller.Detail("e1")).Model!; + + Assert.AreEqual("Specification attributes › Color family › Display order", model.Changes[0].Label); + Assert.AreEqual(change.Path, model.Changes[0].Path); + } + [TestMethod] public async Task ExportCsv_RecordsAuditLogExported() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs new file mode 100644 index 0000000000..da8e775549 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs @@ -0,0 +1,161 @@ +using Grand.Domain.Audit; +using Grand.Web.Admin.Models.AuditLog; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Models; + +[TestClass] +public class AuditChangeTextTests +{ + private static readonly Dictionary Resources = new() { + ["Admin.Common.Yes"] = "Yes", + ["Admin.Common.No"] = "No", + ["Admin.System.AuditLog.Added"] = "added", + ["Admin.System.AuditLog.Removed"] = "removed", + ["Admin.System.AuditLog.ChangedHidden"] = "changed (hidden)" + }; + + private readonly AuditChangeText _text = new(key => Resources.GetValueOrDefault(key, key)); + + private static AuditChange Change(string path, string oldValue, string newValue, params string[] elementNames) + { + return new AuditChange { + Path = path, OldValue = oldValue, NewValue = newValue, + ElementNames = elementNames.Length == 0 ? null : elementNames + }; + } + + private static List AddedSpecification() + { + const string element = "ProductSpecificationAttributes[id=6ae42d82f6567f2f29c48cf7]"; + return [ + Change($"{element}.AttributeTypeId", null, "Option (0)", "Color family"), + Change($"{element}.SpecificationAttributeId", null, "Color family (6ae399b7577fba63f26014ac)", "Color family"), + Change($"{element}.SpecificationAttributeOptionId", null, "Black (6ae399b7577fba63f26014ad)", "Color family"), + Change($"{element}.AllowFiltering", null, "false", "Color family"), + Change($"{element}.ShowOnProductPage", null, "true", "Color family"), + Change($"{element}.DisplayOrder", null, "0", "Color family") + ]; + } + + [TestMethod] + public void AddedElement_OneLineNamedByTheElement() + { + Assert.AreEqual( + "Specification attributes › Color family: added (Attribute type: Option, Specification attribute option: Black, " + + "Allow filtering: No, Show on product page: Yes, Display order: 0)", + _text.Text("Product", AddedSpecification())); + } + + [TestMethod] + public void AddedElement_SummaryWithoutValues() + { + Assert.AreEqual("Specification attributes › Color family (added)", + _text.Summary("Product", AddedSpecification())); + } + + [TestMethod] + public void ChangedElement_FieldsOfTheElement() + { + var changes = new List { + Change("ProductSpecificationAttributes[id=a].DisplayOrder", "0", "2", "Color family"), + Change("ProductSpecificationAttributes[id=a].AllowFiltering", "false", "true", "Color family"), + Change("Name", "Lamp", "Desk lamp") + }; + + Assert.AreEqual("Specification attributes › Color family: Display order: 0 → 2, Allow filtering: No → Yes · " + + "Name: Lamp → Desk lamp", _text.Text("Product", changes)); + Assert.AreEqual("Specification attributes › Color family: Display order, Allow filtering · Name", + _text.Summary("Product", changes)); + } + + [TestMethod] + public void RemovedElement_OnlyItsName() + { + var changes = new List { + Change("ProductCategories[id=c].CategoryId", "Lamps (c1)", null, "Lamps"), + Change("ProductCategories[id=c].DisplayOrder", "1", null, "Lamps") + }; + + Assert.AreEqual("Categories › Lamps: removed", _text.Text("Product", changes)); + } + + [TestMethod] + public void NestedElement_BothNamed() + { + var change = Change("ProductAttributeMappings[id=m].ProductAttributeValues[id=v].PriceAdjustment", "0", "5", + "Color", "Red"); + + Assert.AreEqual("Attribute mappings › Color › Attribute values › Red › Price adjustment", + _text.Label("Product", change)); + } + + [TestMethod] + public void UnnamedElement_NoId() + { + //an entry recorded before element names, or an element with nothing to name it by + var changes = new List { + Change("TierPrices[id=6ae42d82f6567f2f29c48cf7].Quantity", null, "5"), + Change("TierPrices[id=6ae42d82f6567f2f29c48cf7].Price", null, "9.5") + }; + + Assert.AreEqual("Tier prices: added (Quantity: 5, Price: 9.5)", _text.Text("Product", changes)); + } + + [TestMethod] + public void KeyedElement_NamedByItsKey() + { + var changes = new List { Change("UserFields[FirstName].Value", "Ann", "Anna") }; + + Assert.AreEqual("User fields › FirstName: Value: Ann → Anna", _text.Text("Customer", changes)); + } + + [TestMethod] + public void NestedField_InWords() + { + var changes = new List { Change("BillingAddress.FirstName", "Ann", "Anna") }; + + Assert.AreEqual("Billing address › First name: Ann → Anna", _text.Text("Order", changes)); + } + + [TestMethod] + public void MaskedField_ValuesHidden() + { + var changes = new List { new() { Path = "Password", Masked = true } }; + + Assert.AreEqual("Password: changed (hidden)", _text.Text("Customer", changes)); + } + + [TestMethod] + public void Values_CutWithEllipsis() + { + var changes = new List { + Change("FullDescription", null, new string('x', 500)), + new() { Path = "ShortDescription", NewValue = "cut when stored", Truncated = true } + }; + + var text = _text.Text("Product", changes); + + StringAssert.StartsWith(text, $"Full description: — → {new string('x', AuditChangeText.ValueLength)}… · "); + StringAssert.EndsWith(text, "Short description: — → cut when stored…"); + } + + [TestMethod] + public void LongText_CutWithEllipsis() + { + var changes = Enumerable.Range(0, 40).Select(i => Change($"Field{i}", "old value", "new value")).ToList(); + + var text = _text.Text("Product", changes); + + Assert.IsTrue(text.Length <= AuditChangeText.TextLength + 1, text); + StringAssert.EndsWith(text, "…"); + } + + [TestMethod] + public void ValueNotFromAReference_KeepsParentheses() + { + var changes = new List { Change("Name", "Lamp (old)", "Lamp (new)") }; + + Assert.AreEqual("Name: Lamp (old) → Lamp (new)", _text.Text("Product", changes)); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml index bd94acbc8a..27cb2b5396 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/Detail.cshtml @@ -100,7 +100,7 @@ @foreach (var change in Model.Changes) { - @change.Path + @change.Label
@change.Path
@change.OldValue @change.NewValue diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs index f0d366d664..76dedc7658 100644 --- a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -24,16 +24,14 @@ public class AuditLogController( IRequestTranslationService translationService) : BaseAdminController { private const int DefaultDays = 7; - private const int ChangedFieldsLength = 200; private const int HistoryPageSize = 20; private const int TypeHistorySize = 100; - private const int HistoryValueLength = 100; private static readonly string[] CsvColumns = [ "CreatedOnUtc", "Kind", "Source", "ActorEmail", "ActorCustomerId", "ImpersonatedCustomerId", "StoreId", "VendorId", "IpAddress", "UserAgent", "Action", "OperationId", "EntityType", "EntityId", "EntityName", "EntityStoreId", - "Path", "OldValue", "NewValue", "Masked", "Truncated", "Details" + "Path", "ElementNames", "OldValue", "NewValue", "Masked", "Truncated", "Details" ]; public IActionResult Index() @@ -81,7 +79,7 @@ public async Task Detail(string id) Source = SourceName(entry.Source), Kind = KindName(entry.Kind), AppliesTo = AppliesTo(entry, await StoreNames()), - Changes = entry.Changes.Select(ToChange).ToList() + Changes = entry.Changes.Select(c => ToChange(entry.EntityType, c)).ToList() }); } @@ -207,10 +205,10 @@ private string AppliesTo(AuditEntry entry, IDictionary stores) return stores.TryGetValue(entry.EntityStoreId, out var name) ? name : entry.EntityStoreId; } - //the list shows only the changed field names; the values are for the change history popup + //the list shows only what changed; the values are for the change history popup private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary stores, bool withValues) { - var fields = string.Join(", ", entry.Changes.Select(c => c.Path)); + var text = new AuditChangeText(translationService.GetResource); var appliesTo = AppliesTo(entry, stores); return new AuditEntryRowModel { Id = entry.Id, @@ -220,33 +218,25 @@ private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary s Kind = KindName(entry.Kind), What = string.Join(" ", new[] { entry.EntityType, entry.EntityName }.Where(s => !string.IsNullOrEmpty(s))) + (appliesTo == null ? "" : $" ({appliesTo})"), - ChangedFields = fields.Length > ChangedFieldsLength ? fields[..ChangedFieldsLength] + "…" : fields, - ChangesText = withValues - ? string.Join(" · ", entry.Changes.Select(ToChange).Select(c => - $"{c.Path}: {Shorten(c.OldValue)} → {Shorten(c.NewValue)}")) - : null + ChangedFields = text.Summary(entry.EntityType, entry.Changes), + ChangesText = withValues ? text.Text(entry.EntityType, entry.Changes) : null }; } - private static string Shorten(string value) - { - if (value == null) - return "—"; - return value.Length > HistoryValueLength ? value[..HistoryValueLength] + "…" : value; - } - - private AuditChangeModel ToChange(AuditChange change) + private AuditChangeModel ToChange(string entityType, AuditChange change) { + var label = new AuditChangeText(translationService.GetResource).Label(entityType, change); if (change.Masked) { var hidden = translationService.GetResource("Admin.System.AuditLog.ChangedHidden"); - return new AuditChangeModel { Path = change.Path, OldValue = hidden, NewValue = hidden }; + return new AuditChangeModel { Label = label, Path = change.Path, OldValue = hidden, NewValue = hidden }; } var suffix = change.Truncated ? $" … ({translationService.GetResource("Admin.System.AuditLog.Truncated")})" : ""; return new AuditChangeModel { + Label = label, Path = change.Path, OldValue = change.OldValue == null ? null : change.OldValue + suffix, NewValue = change.NewValue == null ? null : change.NewValue + suffix @@ -291,7 +281,8 @@ private static string CsvRow(AuditEntry entry, AuditChange change) entry.ActorCustomerId, entry.ImpersonatedCustomerId, entry.StoreId, entry.VendorId, entry.IpAddress, entry.UserAgent, entry.Action, entry.OperationId, entry.EntityType, entry.EntityId, entry.EntityName, entry.EntityStoreId, - change?.Path, change?.OldValue, change?.NewValue, change?.Masked.ToString(), change?.Truncated.ToString(), + change?.Path, change?.ElementNames == null ? null : string.Join(" › ", change.ElementNames), + change?.OldValue, change?.NewValue, change?.Masked.ToString(), change?.Truncated.ToString(), entry.Details ]; return string.Join(",", values.Select(CsvCell)); diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs new file mode 100644 index 0000000000..98fbf934b5 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs @@ -0,0 +1,233 @@ +using Grand.Domain; +using Grand.Domain.Audit; +using Grand.Infrastructure.Audit; +using System.Text.RegularExpressions; + +namespace Grand.Web.Admin.Models.AuditLog; + +/// +/// Puts the changes of an entry in words for a person: the changes of one list element as one line named by the +/// element, field names instead of property paths, Yes/No, and names without the ids stored next to them. +/// The stored paths and values stay as they are for the entry detail and the CSV export. +/// +public partial class AuditChangeText(Func resource) +{ + public const int ValueLength = 60; + public const int TextLength = 300; + public const int SummaryLength = 200; + + private static readonly Lazy> EntityTypes = new(() => + typeof(BaseEntity).Assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } && typeof(BaseEntity).IsAssignableFrom(t)) + .GroupBy(t => t.Name) + .ToDictionary(g => g.Key, g => g.First())); + + [GeneratedRegex("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")] + private static partial Regex WordBreaks(); + + [GeneratedRegex(@"Id\d*$")] + private static partial Regex IdSuffix(); + + //the " (id)" stored after a name, in one value or in a list of them + [GeneratedRegex(@" \([^()]*\)(?=, |$)")] + private static partial Regex StoredIds(); + + /// + /// Every change with its values, one line per list element, e.g. + /// "Specification attributes › Color family: added (Specification attribute option: Black, Display order: 0)" + /// + public string Text(string entityType, IEnumerable changes) + { + var type = EntityTypes.Value.GetValueOrDefault(entityType ?? ""); + return Cut(string.Join(" · ", Lines(entityType, changes).Select(line => LineText(type, line))), TextLength); + } + + /// + /// What changed without the values, for the audit log list + /// + public string Summary(string entityType, IEnumerable changes) + { + return Cut(string.Join(" · ", Lines(entityType, changes).Select(line => line.Kind switch { + LineKind.Added => $"{line.Label} ({resource("Admin.System.AuditLog.Added")})", + LineKind.Removed => $"{line.Label} ({resource("Admin.System.AuditLog.Removed")})", + _ when line.Element => $"{line.Label}: {string.Join(", ", line.Changes.Select(c => c.Field))}", + _ => line.Label + })), SummaryLength); + } + + /// + /// The field name of one change, e.g. "Specification attributes › Color family › Display order" + /// + public string Label(string entityType, AuditChange change) + { + var line = Lines(entityType, [change]).Single(); + return line.Element ? $"{line.Label} › {line.Changes[0].Field}" : line.Label; + } + + private enum LineKind + { + Changed, + Added, + Removed + } + + private sealed record FieldChange(string Field, AuditChange Change); + + private sealed record Line(string Label, string ElementName, bool Element, List Changes) + { + public LineKind Kind => + !Element ? LineKind.Changed + : Changes.All(c => !c.Change.Masked && c.Change.OldValue == null) ? LineKind.Added + : Changes.All(c => !c.Change.Masked && c.Change.NewValue == null) ? LineKind.Removed + : LineKind.Changed; + } + + //the changes of one list element (the path up to its last [key]) go to one line, in the order they came + private static List Lines(string entityType, IEnumerable changes) + { + var lines = new List(); + var byElement = new Dictionary(); + foreach (var change in changes) + { + var segments = Segments(change.Path); + var last = segments.FindLastIndex(s => s.Key != null); + if (last < 0) + { + lines.Add(new Line(Words(segments, entityType), null, false, [new FieldChange(null, change)])); + continue; + } + + var field = Words(segments.Skip(last + 1), entityType); + var element = string.Join(".", segments.Take(last + 1).Select(s => s.Text)); + if (!byElement.TryGetValue(element, out var line)) + { + var (label, name) = ElementLabel(segments.Take(last + 1).ToList(), change.ElementNames, entityType); + byElement[element] = line = new Line(label, name, true, []); + lines.Add(line); + } + + line.Changes.Add(new FieldChange(field, change)); + } + + return lines; + } + + //"ProductSpecificationAttributes[id=…]" -> "Specification attributes › Color family"; an element without a name + //stays unnamed rather than shown by its id + private static (string Label, string Name) ElementLabel(List segments, IList names, + string entityType) + { + var parts = new List(); + string name = null; + var named = 0; + foreach (var segment in segments) + { + parts.Add(Word(segment.Name, entityType)); + if (segment.Key == null) + continue; + + name = segment.Key.StartsWith("id=") + ? names?.ElementAtOrDefault(named++) is { Length: > 0 } elementName ? elementName : null + : int.TryParse(segment.Key, out var index) + ? $"#{index + 1}" + : segment.Key; + if (name != null) + parts.Add(name); + } + + return (string.Join(" › ", parts), name); + } + + private string LineText(Type type, Line line) + { + string Value(FieldChange field, string value) => ValueText(type, field.Change, value); + + switch (line.Kind) + { + case LineKind.Added: + //the field that names the element is already in the label + var set = line.Changes + .Select(c => (c.Field, Value: Value(c, c.Change.NewValue))) + .Where(c => c.Value != line.ElementName) + .Select(c => $"{c.Field}: {c.Value}") + .ToList(); + var added = $"{line.Label}: {resource("Admin.System.AuditLog.Added")}"; + return set.Count == 0 ? added : $"{added} ({string.Join(", ", set)})"; + case LineKind.Removed: + return $"{line.Label}: {resource("Admin.System.AuditLog.Removed")}"; + default: + var fields = line.Changes.Select(c => c.Change.Masked + ? $"{c.Field ?? line.Label}: {resource("Admin.System.AuditLog.ChangedHidden")}" + : $"{c.Field ?? line.Label}: {Value(c, c.Change.OldValue)} → {Value(c, c.Change.NewValue)}"); + return line.Element ? $"{line.Label}: {string.Join(", ", fields)}" : string.Join(", ", fields); + } + } + + private string ValueText(Type type, AuditChange change, string value) + { + if (value == null) + return "—"; + if (value is "true" or "false") + return resource(value == "true" ? "Admin.Common.Yes" : "Admin.Common.No"); + if (type != null && AuditReferences.HasNamedValues(type, change.Path)) + value = StoredIds().Replace(value, ""); + //a value cut when it was stored ends in "…" too + var text = Cut(value, ValueLength); + return change.Truncated && !text.EndsWith('…') ? text + "…" : text; + } + + private static string Cut(string value, int length) + { + return value.Length > length ? value[..length].TrimEnd() + "…" : value; + } + + private sealed record Segment(string Text, string Name, string Key); + + //split on the dots outside [ ], so a key with a dot stays whole + private static List Segments(string path) + { + var segments = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i <= path.Length; i++) + { + if (i < path.Length) + { + if (path[i] == '[') depth++; + else if (path[i] == ']') depth--; + if (path[i] != '.' || depth > 0) + continue; + } + + var text = path[start..i]; + var bracket = text.IndexOf('['); + segments.Add(bracket < 0 + ? new Segment(text, text, null) + : new Segment(text, text[..bracket], text[(bracket + 1)..].TrimEnd(']'))); + start = i + 1; + } + + return segments; + } + + private static string Words(IEnumerable segments, string entityType) + { + return string.Join(" › ", segments.Select(s => Word(s.Name, entityType))); + } + + //"ProductSpecificationAttributes" of a Product -> "Specification attributes", "SpecificationAttributeOptionId" -> + //"Specification attribute option" + private static string Word(string name, string entityType) + { + var word = IdSuffix().Replace(name, "") is { Length: > 0 } withoutId ? withoutId : name; + if (!string.IsNullOrEmpty(entityType) && word.Length > entityType.Length && word.StartsWith(entityType) + && char.IsUpper(word[entityType.Length])) + word = word[entityType.Length..]; + + var words = WordBreaks().Split(word).Where(w => w != "").ToList(); + return string.Join(" ", words.Select((w, i) => + i == 0 ? char.ToUpperInvariant(w[0]) + w[1..] + : w.All(char.IsUpper) ? w + : w.ToLowerInvariant())); + } +} diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs index c72544c3e6..10ebfb4695 100644 --- a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditEntryModel.cs @@ -13,16 +13,25 @@ public class AuditEntryRowModel public string Source { get; set; } public string Kind { get; set; } public string What { get; set; } + /// + /// What changed, without the values, shortened + /// public string ChangedFields { get; set; } /// - /// "Path: before → after" of every change, shortened, for the change history popup + /// Every change with its values in words (see ), shortened, for the change + /// history popup /// public string ChangesText { get; set; } } public class AuditChangeModel { + /// + /// The field in words, e.g. "Specification attributes › Color family › Display order" + /// + public string Label { get; set; } + public string Path { get; set; } public string OldValue { get; set; } public string NewValue { get; set; } diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index d6b2e3692aa62605f993fe527e8b48539189c710..0e2b0627c2cd93cc6af70da64be55f9564a04380 100644 GIT binary patch delta 123 zcmdnAJNekYF7M2#)7Pc1l7LFFq7OocV7M>Q~7QQX~V#lYuEn~EqUi6tW zWBP*|{0iGej`JUoo@&DD#9G9V%8)zVF-ItR`iH~(GSe@dCxi(G! delta 83 zcmX@MFL~4MF7M2#)7Pc1l7LFFq7OocV7M>Q~7QQX~V#l}NInKXNx;^6w dKM)H5u^ changed (hidden) + + added + + + removed + truncated From 84515df1b875ef3799c63aab8348849d53647dad Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 07:16:50 +0100 Subject: [PATCH 2/6] Show the change history of a shipment and name shipments, returns, payments and tasks in the audit log --- src/Core/Grand.Domain/Orders/MerchandiseReturn.cs | 1 + src/Core/Grand.Domain/Payments/PaymentTransaction.cs | 1 + src/Core/Grand.Domain/Seo/EntityUrl.cs | 1 + src/Core/Grand.Domain/Shipping/Shipment.cs | 1 + src/Core/Grand.Domain/Tasks/ScheduleTask.cs | 1 + .../Views/AdminShared/Shipment/ShipmentDetails.cshtml | 1 + 6 files changed, 6 insertions(+) diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs index 534747bc22..b5a9a10227 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturn.cs @@ -11,6 +11,7 @@ namespace Grand.Domain.Orders; ///
public class MerchandiseReturn : BaseEntity { + [AuditName] public int ReturnNumber { get; set; } /// diff --git a/src/Core/Grand.Domain/Payments/PaymentTransaction.cs b/src/Core/Grand.Domain/Payments/PaymentTransaction.cs index d9ba05fa5f..9732d0aa91 100644 --- a/src/Core/Grand.Domain/Payments/PaymentTransaction.cs +++ b/src/Core/Grand.Domain/Payments/PaymentTransaction.cs @@ -30,6 +30,7 @@ public class PaymentTransaction : BaseEntity /// /// Gets or sets order code /// + [AuditName] public string OrderCode { get; set; } /// diff --git a/src/Core/Grand.Domain/Seo/EntityUrl.cs b/src/Core/Grand.Domain/Seo/EntityUrl.cs index 4fd2ab2e7d..ce53d45953 100644 --- a/src/Core/Grand.Domain/Seo/EntityUrl.cs +++ b/src/Core/Grand.Domain/Seo/EntityUrl.cs @@ -21,6 +21,7 @@ public class EntityUrl : BaseEntity /// /// Gets or sets the slug /// + [AuditName] public string Slug { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/Shipment.cs b/src/Core/Grand.Domain/Shipping/Shipment.cs index 5eb4146091..5fa97a531b 100644 --- a/src/Core/Grand.Domain/Shipping/Shipment.cs +++ b/src/Core/Grand.Domain/Shipping/Shipment.cs @@ -63,6 +63,7 @@ public class Shipment : BaseEntity /// /// Gets the shipment number /// + [AuditName] public virtual int ShipmentNumber { get; set; } /// diff --git a/src/Core/Grand.Domain/Tasks/ScheduleTask.cs b/src/Core/Grand.Domain/Tasks/ScheduleTask.cs index f4355bf3db..f85bb8169a 100644 --- a/src/Core/Grand.Domain/Tasks/ScheduleTask.cs +++ b/src/Core/Grand.Domain/Tasks/ScheduleTask.cs @@ -5,6 +5,7 @@ namespace Grand.Domain.Tasks; public class ScheduleTask : BaseEntity { + [AuditName] public string ScheduleTaskName { get; set; } public bool Enabled { get; set; } public bool StopOnError { get; set; } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml index 09b5ba7f93..40ea5b7d39 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Shipment/ShipmentDetails.cshtml @@ -22,6 +22,7 @@ back-url="@Url.Action("List")" back-text="@Loc["Admin.Orders.Shipments.BackToList"]"> + @if (Model.CanShip) { Date: Fri, 30 Oct 2026 07:24:53 +0100 Subject: [PATCH 3/6] Show the notes of a record in its change history A note is a record of its own, so adding one to a merchandise return, shipment or customer never showed in that record's history; order notes were recorded twice (the note itself and a hand-written entry on the order). [AuditOwner] marks the property holding the id of the record a note belongs to. The tracker stamps OwnerType/OwnerId on the note's entry, and the owner's store when the note has none, and the history of a record also lists the entries owned by it (new index OwnerType_OwnerId_CreatedOnUtc_Id). The hand-written IAuditEventRecorder.OwnedChange is gone. --- .../Services/Audit/AuditService.cs | 8 +++- src/Core/Grand.Data/Audit/AuditIndexes.cs | 3 ++ src/Core/Grand.Domain/Audit/AuditEntry.cs | 11 ++++++ .../Grand.Domain/Audit/AuditOwnerAttribute.cs | 9 +++++ .../Grand.Domain/Customers/CustomerNote.cs | 1 + .../Orders/MerchandiseReturnNote.cs | 2 + src/Core/Grand.Domain/Orders/OrderNote.cs | 2 + .../Grand.Domain/Shipping/ShipmentNote.cs | 5 +++ .../Audit/AuditReferences.cs | 38 +++++++++++++++++++ .../Audit/AuditTracker.cs | 1 + .../Services/Audit/AuditServiceTests.cs | 18 +++++++++ .../Audit/AuditReferencesTests.cs | 29 ++++++++++++++ .../BaseOrderManagementControllerTests.cs | 19 +--------- .../Models/AuditChangeTextTests.cs | 16 ++++++++ .../Audit/AuditEventRecorderTests.cs | 22 ----------- .../Controllers/AuditLogController.cs | 2 +- .../Models/AuditLog/AuditChangeText.cs | 16 ++++++++ .../BaseOrderManagementController.cs | 13 +------ .../Audit/AuditEventRecorder.cs | 14 ------- .../Audit/IAuditEventRecorder.cs | 7 ---- 20 files changed, 162 insertions(+), 74 deletions(-) create mode 100644 src/Core/Grand.Domain/Audit/AuditOwnerAttribute.cs diff --git a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs index 56edae1083..34ad243af7 100644 --- a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -119,9 +119,13 @@ private IQueryable Query(AuditEntrySearch search) query = query.Where(x => x.Source == search.Source.Value); if (search.Kind.HasValue) query = query.Where(x => x.Kind == search.Kind.Value); - if (!string.IsNullOrEmpty(search.EntityType)) + //the history of one record also holds the records that belong to it (its notes) + if (!string.IsNullOrEmpty(search.EntityType) && !string.IsNullOrEmpty(search.EntityId)) + query = query.Where(x => (x.EntityType == search.EntityType && x.EntityId == search.EntityId) + || (x.OwnerType == search.EntityType && x.OwnerId == search.EntityId)); + else if (!string.IsNullOrEmpty(search.EntityType)) query = query.Where(x => x.EntityType == search.EntityType); - if (!string.IsNullOrEmpty(search.EntityId)) + else if (!string.IsNullOrEmpty(search.EntityId)) query = query.Where(x => x.EntityId == search.EntityId); if (search.EntityNames is { Count: > 0 }) { diff --git a/src/Core/Grand.Data/Audit/AuditIndexes.cs b/src/Core/Grand.Data/Audit/AuditIndexes.cs index b5e517f7dd..75d5d0b86c 100644 --- a/src/Core/Grand.Data/Audit/AuditIndexes.cs +++ b/src/Core/Grand.Data/Audit/AuditIndexes.cs @@ -16,6 +16,9 @@ await databaseContext.CreateIndex(repository, await databaseContext.CreateIndex(repository, OrderBuilder.Create().Ascending(x => x.EntityType).Ascending(x => x.EntityId) .Descending(x => x.CreatedOnUtc).Descending(x => x.Id), "EntityType_EntityId_CreatedOnUtc_Id"); + await databaseContext.CreateIndex(repository, + OrderBuilder.Create().Ascending(x => x.OwnerType).Ascending(x => x.OwnerId) + .Descending(x => x.CreatedOnUtc).Descending(x => x.Id), "OwnerType_OwnerId_CreatedOnUtc_Id"); await databaseContext.CreateIndex(repository, OrderBuilder.Create().Ascending(x => x.ActorCustomerId).Descending(x => x.CreatedOnUtc) .Descending(x => x.Id), "ActorCustomerId_CreatedOnUtc_Id"); diff --git a/src/Core/Grand.Domain/Audit/AuditEntry.cs b/src/Core/Grand.Domain/Audit/AuditEntry.cs index eb1022e312..0a24fe0200 100644 --- a/src/Core/Grand.Domain/Audit/AuditEntry.cs +++ b/src/Core/Grand.Domain/Audit/AuditEntry.cs @@ -63,6 +63,17 @@ public class AuditEntry : BaseEntity /// public string EntityStoreId { get; set; } + /// + /// Gets or sets the type of the record the changed record belongs to (Order for an order note, see + /// ); null when it belongs to none + /// + public string OwnerType { get; set; } + + /// + /// Gets or sets the id of the record the changed record belongs to + /// + public string OwnerId { get; set; } + public IList Changes { get; set; } = new List(); /// diff --git a/src/Core/Grand.Domain/Audit/AuditOwnerAttribute.cs b/src/Core/Grand.Domain/Audit/AuditOwnerAttribute.cs new file mode 100644 index 0000000000..5b88e84e1f --- /dev/null +++ b/src/Core/Grand.Domain/Audit/AuditOwnerAttribute.cs @@ -0,0 +1,9 @@ +namespace Grand.Domain.Audit; + +/// +/// Marks the property holding the id of the record this one belongs to (the order of an order note), so the +/// entries of this record also show in that record's change history. The property carries +/// [AuditReference] naming the owner's type. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class AuditOwnerAttribute : Attribute; diff --git a/src/Core/Grand.Domain/Customers/CustomerNote.cs b/src/Core/Grand.Domain/Customers/CustomerNote.cs index 7600141c28..262ea1fb13 100644 --- a/src/Core/Grand.Domain/Customers/CustomerNote.cs +++ b/src/Core/Grand.Domain/Customers/CustomerNote.cs @@ -11,6 +11,7 @@ public class CustomerNote : BaseEntity /// Gets or sets the customer identifier /// [AuditReference(typeof(Customer))] + [AuditOwner] public string CustomerId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs b/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs index 5e254916ca..4f0fab0535 100644 --- a/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs +++ b/src/Core/Grand.Domain/Orders/MerchandiseReturnNote.cs @@ -10,6 +10,8 @@ public class MerchandiseReturnNote : BaseEntity /// /// Gets or sets the merchandise return identifier /// + [AuditReference(typeof(MerchandiseReturn))] + [AuditOwner] public string MerchandiseReturnId { get; set; } /// diff --git a/src/Core/Grand.Domain/Orders/OrderNote.cs b/src/Core/Grand.Domain/Orders/OrderNote.cs index 5cc69ebac1..d1577e584a 100644 --- a/src/Core/Grand.Domain/Orders/OrderNote.cs +++ b/src/Core/Grand.Domain/Orders/OrderNote.cs @@ -11,11 +11,13 @@ public class OrderNote : BaseEntity /// Gets or sets the order identifier /// [AuditReference(typeof(Order))] + [AuditOwner] public string OrderId { get; set; } /// /// Gets or sets the note /// + [AuditName] public string Note { get; set; } /// diff --git a/src/Core/Grand.Domain/Shipping/ShipmentNote.cs b/src/Core/Grand.Domain/Shipping/ShipmentNote.cs index baf15e6414..99ebc59d41 100644 --- a/src/Core/Grand.Domain/Shipping/ShipmentNote.cs +++ b/src/Core/Grand.Domain/Shipping/ShipmentNote.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Shipping; /// @@ -8,11 +10,14 @@ public class ShipmentNote : BaseEntity /// /// Gets or sets the shipment identifier /// + [AuditReference(typeof(Shipment))] + [AuditOwner] public string ShipmentId { get; set; } /// /// Gets or sets the note /// + [AuditName] public string Note { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs index d20750df63..60f7e9869d 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditReferences.cs @@ -15,8 +15,10 @@ public partial class AuditReferences(IAuditRecordReader reader) { private static readonly ConcurrentDictionary NameProperties = new(); private static readonly ConcurrentDictionary<(Type, string), PropertyInfo> Properties = new(); + private static readonly ConcurrentDictionary Owners = new(); private readonly Dictionary<(Type Target, string List, string Key, string Owner, string Id), string> _names = new(); + private readonly Dictionary<(Type Target, string Id), string> _ownerStores = new(); [GeneratedRegex(@"\[[^\]]*\]")] private static partial Regex ElementKeys(); @@ -122,6 +124,42 @@ private async Task ElementName(Type elementType, string path, IDictionar return await NameOrNull(reference, owner, value); } + /// + /// Links the entry of a record that belongs to another (see ) to that record, + /// so it shows in the owner's change history, and to the owner's store when it has none of its own + /// + public async Task LinkOwner(Type entityType, AuditEntry entry, IDictionary snapshot) + { + var owner = Owners.GetOrAdd(entityType, t => t.GetProperties().FirstOrDefault(p => + p.IsDefined(typeof(AuditOwnerAttribute)) && p.IsDefined(typeof(AuditReferenceAttribute)))); + if (owner == null || snapshot == null || !snapshot.TryGetValue(owner.Name, out var ownerId) + || string.IsNullOrEmpty(ownerId)) + return; + + var target = owner.GetCustomAttribute()!.Target; + entry.OwnerType = target.Name; + entry.OwnerId = ownerId; + if (entry.EntityStoreId != null) + return; + + //a store that cannot be read leaves the entry without one, never the entry out + try + { + if (!_ownerStores.TryGetValue((target, ownerId), out var storeId)) + { + var record = await reader.Read(target, ownerId, null, "StoreId"); + _ownerStores[(target, ownerId)] = storeId = + record != null && record.TryGetValue("StoreId", out var stored) ? stored : null; + } + + entry.EntityStoreId = storeId; + } + catch (Exception) + { + //no store + } + } + /// /// Whether the values of a path are stored as "name (id)": a reference, or a number of an enum /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index bf4b8716dc..0500a82bd6 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -237,6 +237,7 @@ private static async Task> BuildEntries( { await references.NameElements(group.Key, entry.Changes, item.Value.Before, after); await references.Describe(group.Key, entry.Changes, item.Value.Before, after); + await references.LinkOwner(group.Key, entry, after ?? item.Value.Before); } } catch (Exception) diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs index 588a25e4de..9d7f90d840 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -57,6 +57,24 @@ public async Task GetEntries_FiltersByEntityTypeAndId_NewestFirst() CollectionAssert.AreEqual(new[] { newer.Id, older.Id }, result.Select(x => x.Id).ToArray()); } + [TestMethod] + public async Task GetEntries_RecordHistory_HoldsTheRecordsThatBelongToIt() + { + var update = await Add("MerchandiseReturn", "r1", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc)); + var note = await Add("MerchandiseReturnNote", "n1", new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc)); + note.OwnerType = "MerchandiseReturn"; + note.OwnerId = "r1"; + await _repository.UpdateAsync(note); + var otherNote = await Add("MerchandiseReturnNote", "n2", new DateTime(2026, 10, 3, 0, 0, 0, DateTimeKind.Utc)); + otherNote.OwnerType = "MerchandiseReturn"; + otherNote.OwnerId = "r2"; + await _repository.UpdateAsync(otherNote); + + var result = await _service.GetEntries(new AuditEntrySearch { EntityType = "MerchandiseReturn", EntityId = "r1" }); + + CollectionAssert.AreEqual(new[] { note.Id, update.Id }, result.Select(x => x.Id).ToArray()); + } + [TestMethod] public async Task GetEntries_FiltersByEntityNames() { diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs index 8d30ee8c4b..39cc58a55e 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditReferencesTests.cs @@ -117,6 +117,9 @@ public void EveryReferenceMark_PointsToANamedRecord() foreach (var type in typeof(Grand.Domain.BaseEntity).Assembly.GetTypes()) foreach (var property in type.GetProperties()) { + if (property.IsDefined(typeof(AuditOwnerAttribute), false) + && !property.IsDefined(typeof(AuditReferenceAttribute), false)) + problems.Add($"{type.Name}.{property.Name}: an owner needs [AuditReference] naming its type"); if (property.GetCustomAttributes(typeof(AuditReferenceAttribute), false).FirstOrDefault() is not AuditReferenceAttribute mark) continue; @@ -244,6 +247,32 @@ public async Task ElementWithoutName_NoNames() Assert.IsNull(await ElementNames("Name", null, product)); } + [TestMethod] + public async Task Note_LinkedToItsOwnerAndTheOwnersStore() + { + _records[(typeof(MerchandiseReturn), "r1")] = new Dictionary { ["StoreId"] = "s2" }; + var entry = new AuditEntry { EntityType = nameof(MerchandiseReturnNote) }; + + await _references.LinkOwner(typeof(MerchandiseReturnNote), entry, + new Dictionary { ["MerchandiseReturnId"] = "r1", ["Note"] = "Called" }); + + Assert.AreEqual(nameof(MerchandiseReturn), entry.OwnerType); + Assert.AreEqual("r1", entry.OwnerId); + Assert.AreEqual("s2", entry.EntityStoreId); + _reader.Verify(r => r.Read(typeof(MerchandiseReturn), "r1", null, "StoreId"), Times.Once); + } + + [TestMethod] + public async Task RecordWithoutOwner_NotLinked() + { + var entry = new AuditEntry { EntityType = nameof(Product) }; + + await _references.LinkOwner(typeof(Product), entry, new Dictionary { ["Name"] = "Lamp" }); + + Assert.IsNull(entry.OwnerType); + Assert.IsNull(entry.OwnerId); + } + [TestMethod] public async Task UnmarkedOrMissing_KeepsTheStoredValue() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs index 5f872aa42d..723ed60c8d 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseOrderManagementControllerTests.cs @@ -22,7 +22,6 @@ using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Grand.Web.Common.Audit; using Moq; using Grand.Web.Common.Localization; @@ -445,20 +444,6 @@ public async Task AddProductToOrderSearch_ScopeDenies_ReturnsGridError() Assert.IsNotNull(GridErrors(result)); } - [TestMethod] - public async Task OrderNoteAdd_RecordedOnTheOrder() - { - var order = new Order { Id = "o1", OrderNumber = 7, StoreId = "s1" }; - _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); - _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); - _orderNoteViewModelServiceMock.Setup(v => v.InsertOrderNote(order, null, false, "msg")).ReturnsAsync(true); - var recorder = new Mock(); - - await _controller.OrderNoteAdd("o1", null, false, "msg", recorder.Object); - - recorder.Verify(r => r.OwnedChange("Order", "o1", "7", "s1", "OrderNote", null, "msg"), Times.Once); - } - [TestMethod] public async Task OrderNoteAdd_FileRefused_ReturnsResultFalseWithMessage() { @@ -467,7 +452,7 @@ public async Task OrderNoteAdd_FileRefused_ReturnsResultFalseWithMessage() _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(true); _orderNoteViewModelServiceMock.Setup(v => v.InsertOrderNote(order, "d1", false, "msg")).ReturnsAsync(false); - var result = await _controller.OrderNoteAdd("o1", "d1", false, "msg", new Mock().Object); + var result = await _controller.OrderNoteAdd("o1", "d1", false, "msg"); var value = (result as JsonResult)?.Value; Assert.IsFalse((bool)value!.GetType().GetProperty("Result")!.GetValue(value)!); @@ -585,7 +570,7 @@ public async Task OrderNoteAdd_ScopeDenies_ReturnsJsonResultFalse() _orderServiceMock.Setup(s => s.GetOrderById("o1")).ReturnsAsync(order); _scopeMock.Setup(s => s.HasAccess(order)).ReturnsAsync(false); - var result = await _controller.OrderNoteAdd("o1", null, false, "msg", new Mock().Object); + var result = await _controller.OrderNoteAdd("o1", null, false, "msg"); var json = result as JsonResult; Assert.IsNotNull(json); diff --git a/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs index da8e775549..abcd882188 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs @@ -151,6 +151,22 @@ public void LongText_CutWithEllipsis() StringAssert.EndsWith(text, "…"); } + [TestMethod] + public void NoteInItsOwnersHistory_SaysWhatItIs() + { + var added = new AuditEntry { + EntityType = "MerchandiseReturnNote", OwnerType = "MerchandiseReturn", OwnerId = "r1", + Changes = [Change("Note", null, "Called the customer")] + }; + var deleted = new AuditEntry { + EntityType = "MerchandiseReturnNote", EntityName = "Called the customer", OwnerType = "MerchandiseReturn", + OwnerId = "r1" + }; + + Assert.AreEqual("Merchandise return note: Note: — → Called the customer", _text.Text(added)); + Assert.AreEqual("Merchandise return note Called the customer", _text.Text(deleted)); + } + [TestMethod] public void ValueNotFromAReference_KeepsParentheses() { diff --git a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs index 9ff9238244..6c9219c4df 100644 --- a/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs +++ b/src/Tests/Grand.Web.Common.Tests/Audit/AuditEventRecorderTests.cs @@ -94,28 +94,6 @@ public async Task AccessDenied_RecordsPermissionAndAction() Assert.AreEqual("permission: ManagePlugins / Edit", entry.Details); } - [TestMethod] - public async Task OwnedChange_ScopedToTheOwnersStore() - { - await _recorder.OwnedChange("Order", "o1", "7", "s2", "OrderNote", null, "msg"); - - var entry = _recorded.Single(); - Assert.AreEqual(AuditKind.Update, entry.Kind); - Assert.AreEqual("o1", entry.EntityId); - Assert.AreEqual("s2", entry.EntityStoreId); - Assert.AreEqual("msg", entry.Changes.Single().NewValue); - } - - [TestMethod] - public async Task OwnedChange_LongNote_CutAndMarked() - { - await _recorder.OwnedChange("Order", "o1", "7", "s2", "OrderNote", null, new string('x', 3000)); - - var change = _recorded.Single().Changes.Single(); - Assert.AreEqual(AuditDiff.MaxValueLength, change.NewValue.Length); - Assert.IsTrue(change.Truncated); - } - [TestMethod] public async Task Plugin_RecordsNameAndChange() { diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs index 76dedc7658..18c1b86d12 100644 --- a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -219,7 +219,7 @@ private AuditEntryRowModel ToRow(AuditEntry entry, IDictionary s What = string.Join(" ", new[] { entry.EntityType, entry.EntityName }.Where(s => !string.IsNullOrEmpty(s))) + (appliesTo == null ? "" : $" ({appliesTo})"), ChangedFields = text.Summary(entry.EntityType, entry.Changes), - ChangesText = withValues ? text.Text(entry.EntityType, entry.Changes) : null + ChangesText = withValues ? text.Text(entry) : null }; } diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs index 98fbf934b5..3c880d12db 100644 --- a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs @@ -42,6 +42,22 @@ public string Text(string entityType, IEnumerable changes) return Cut(string.Join(" · ", Lines(entityType, changes).Select(line => LineText(type, line))), TextLength); } + /// + /// The changes of an entry; an entry of a record that belongs to another (a note in its order's history) says + /// what it is, e.g. "Order note: Note: — → Called the customer", or "Order note Called the customer" when deleted + /// + public string Text(AuditEntry entry) + { + var text = Text(entry.EntityType, entry.Changes); + if (entry.OwnerType == null) + return text; + + var what = Word(entry.EntityType, null); + if (text == "") + return string.IsNullOrEmpty(entry.EntityName) ? what : $"{what} {Cut(entry.EntityName, ValueLength)}"; + return Cut($"{what}: {text}", TextLength); + } + /// /// What changed without the values, for the audit log list /// diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs index 76e35b8019..7c63ef23e0 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseOrderManagementController.cs @@ -6,7 +6,6 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; using Grand.Domain.Orders; -using Grand.Web.Common.Audit; using Grand.Domain.Permissions; using Grand.Infrastructure; using Grand.Mediator; @@ -660,8 +659,7 @@ public async Task OrderNotesSelect(string orderId, Grand.Web.Comm [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] - public async Task OrderNoteAdd(string orderId, string downloadId, bool displayToCustomer, string message, - [FromServices] IAuditEventRecorder auditEventRecorder) + public async Task OrderNoteAdd(string orderId, string downloadId, bool displayToCustomer, string message) { var order = await OrderService.GetOrderById(orderId); if (order == null || !await Scope.HasAccess(order)) return Json(new { Result = false }); @@ -672,25 +670,18 @@ public async Task OrderNoteAdd(string orderId, string downloadId, Message = TranslationService.GetResource("Admin.Orders.OrderNotes.DownloadNotOfOrder") }); - //a note is a record of its own; this puts it in the order's change history - await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), order.StoreId, - "OrderNote", null, message); return Json(new { Result = true }); } [PermissionAuthorizeAction(PermissionActionName.Delete)] [HttpPost] - public async Task OrderNoteDelete(string id, string orderId, - [FromServices] IAuditEventRecorder auditEventRecorder) + public async Task OrderNoteDelete(string id, string orderId) { var order = await OrderService.GetOrderById(orderId) ?? throw new ArgumentException("No order found with the specified id"); if (!await Scope.HasAccess(order)) return Json(new { Result = false }); - var note = (await OrderService.GetOrderNotes(order.Id)).FirstOrDefault(n => n.Id == id); await orderNoteViewModelService.DeleteOrderNote(order, id); - await auditEventRecorder.OwnedChange(nameof(Order), order.Id, order.OrderNumber.ToString(), order.StoreId, - "OrderNote", note?.Note, null); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs index 5658d16f5e..4d566a9992 100644 --- a/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/AuditEventRecorder.cs @@ -3,7 +3,6 @@ using Grand.Domain.Audit; using Grand.Domain.Customers; using Grand.Infrastructure; -using Grand.Infrastructure.Audit; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -89,19 +88,6 @@ public Task Plugin(string pluginName, string change) }); } - public Task OwnedChange(string entityType, string entityId, string entityName, string entityStoreId, string path, - string oldValue, string newValue) - { - return Record(AuditKind.Update, CurrentStaff(), entry => - { - entry.EntityType = entityType; - entry.EntityId = entityId; - entry.EntityName = entityName; - entry.EntityStoreId = entityStoreId; - entry.Changes = [AuditDiff.Change(path, oldValue, newValue)]; - }); - } - private async Task RecordSignInEvent(AuditKind kind, Customer customer, string reason) { try diff --git a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs index 20af841f09..551471153c 100644 --- a/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs +++ b/src/Web/Grand.Web.Common/Audit/IAuditEventRecorder.cs @@ -40,11 +40,4 @@ public interface IAuditEventRecorder /// Records a plugin installed, uninstalled, removed or uploaded by the current staff member /// Task Plugin(string pluginName, string change); - - /// - /// Records, on a record, a change made through a separate record that belongs to it (a note of an order), so it - /// shows in the owner's change history - /// - Task OwnedChange(string entityType, string entityId, string entityName, string entityStoreId, string path, - string oldValue, string newValue); } From 44a7eeebd46aa73089bf72d6a2a1b0fddc0e23ca Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 07:27:04 +0100 Subject: [PATCH 4/6] Name news, blog and knowledge base comments in the audit log, and show blog and article comments in their post's history --- src/Core/Grand.Domain/Blogs/BlogComment.cs | 2 ++ .../Knowledgebase/KnowledgebaseArticleComment.cs | 2 ++ src/Core/Grand.Domain/News/NewsComment.cs | 1 + src/Core/Grand.Infrastructure/Audit/AuditTracker.cs | 5 ++++- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Core/Grand.Domain/Blogs/BlogComment.cs b/src/Core/Grand.Domain/Blogs/BlogComment.cs index 8d29ab01ac..9936d86987 100644 --- a/src/Core/Grand.Domain/Blogs/BlogComment.cs +++ b/src/Core/Grand.Domain/Blogs/BlogComment.cs @@ -24,6 +24,7 @@ public class BlogComment : BaseEntity /// /// Gets or sets the comment text /// + [AuditName] public string CommentText { get; set; } /// @@ -35,5 +36,6 @@ public class BlogComment : BaseEntity /// Gets or sets the blog post identifier /// [AuditReference(typeof(BlogPost))] + [AuditOwner] public string BlogPostId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs index 92352f0d2c..5b376b8878 100644 --- a/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs +++ b/src/Core/Grand.Domain/Knowledgebase/KnowledgebaseArticleComment.cs @@ -14,6 +14,7 @@ public class KnowledgebaseArticleComment : BaseEntity /// /// Gets or sets the comment text /// + [AuditName] public string CommentText { get; set; } /// @@ -25,5 +26,6 @@ public class KnowledgebaseArticleComment : BaseEntity /// Gets or sets the blog post identifier /// [AuditReference(typeof(KnowledgebaseArticle))] + [AuditOwner] public string ArticleId { get; set; } } \ No newline at end of file diff --git a/src/Core/Grand.Domain/News/NewsComment.cs b/src/Core/Grand.Domain/News/NewsComment.cs index 3559c2480c..0428c2c598 100644 --- a/src/Core/Grand.Domain/News/NewsComment.cs +++ b/src/Core/Grand.Domain/News/NewsComment.cs @@ -12,6 +12,7 @@ public class NewsComment : SubBaseEntity /// /// Gets or sets the comment title /// + [AuditName] public string CommentTitle { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 0500a82bd6..9a1de4bd21 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -298,8 +298,11 @@ private static AuditEntry Build(Type type, string entityId, Pending pending, private static string NameOf(Type type, IDictionary snapshot) { + //a name can be a note or a comment: kept to the length of a stored value var property = AuditReferences.NameProperty(type); - return snapshot != null && snapshot.TryGetValue(property, out var name) && name != "" ? name : null; + if (snapshot == null || !snapshot.TryGetValue(property, out var name) || name == "") + return null; + return name.Length > AuditDiff.MaxValueLength ? name[..AuditDiff.MaxValueLength] : name; } //"" is a record of all stores, null one without a store From 8b691d73cb5444672e58264751cf3809a8582db8 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 07:30:41 +0100 Subject: [PATCH 5/6] Show the change history of permissions: one history may cover several entity types --- .../Services/Audit/AuditService.cs | 21 ++++++++++++------- .../Common/Audit/AuditEntrySearch.cs | 4 ++++ .../Services/Audit/AuditServiceTests.cs | 12 +++++++++++ .../Admin/Views/AuditLog/HistoryPopup.cshtml | 2 +- .../Areas/Admin/Views/Permission/Index.cshtml | 1 + .../Controllers/AuditLogController.cs | 3 +++ 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs index 34ad243af7..4d2f8050b8 100644 --- a/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs +++ b/src/Business/Grand.Business.Common/Services/Audit/AuditService.cs @@ -119,14 +119,21 @@ private IQueryable Query(AuditEntrySearch search) query = query.Where(x => x.Source == search.Source.Value); if (search.Kind.HasValue) query = query.Where(x => x.Kind == search.Kind.Value); + var types = search.EntityType?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .ToList() ?? []; //the history of one record also holds the records that belong to it (its notes) - if (!string.IsNullOrEmpty(search.EntityType) && !string.IsNullOrEmpty(search.EntityId)) - query = query.Where(x => (x.EntityType == search.EntityType && x.EntityId == search.EntityId) - || (x.OwnerType == search.EntityType && x.OwnerId == search.EntityId)); - else if (!string.IsNullOrEmpty(search.EntityType)) - query = query.Where(x => x.EntityType == search.EntityType); - else if (!string.IsNullOrEmpty(search.EntityId)) - query = query.Where(x => x.EntityId == search.EntityId); + if (types.Count == 1 && !string.IsNullOrEmpty(search.EntityId)) + query = query.Where(x => (x.EntityType == types[0] && x.EntityId == search.EntityId) + || (x.OwnerType == types[0] && x.OwnerId == search.EntityId)); + else + { + if (types.Count == 1) + query = query.Where(x => x.EntityType == types[0]); + else if (types.Count > 1) + query = query.Where(x => types.Contains(x.EntityType)); + if (!string.IsNullOrEmpty(search.EntityId)) + query = query.Where(x => x.EntityId == search.EntityId); + } if (search.EntityNames is { Count: > 0 }) { var names = search.EntityNames.ToList(); diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs index f95305c5f2..7b03df08e7 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Common/Audit/AuditEntrySearch.cs @@ -15,7 +15,11 @@ public class AuditEntrySearch public string SearchText { get; set; } public AuditSource? Source { get; set; } public AuditKind? Kind { get; set; } + /// + /// One entity type, or several separated by commas (the records one screen edits, e.g. "Permission,PermissionAction") + /// public string EntityType { get; set; } + public string EntityId { get; set; } /// diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs index 9d7f90d840..c99c824208 100644 --- a/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs +++ b/src/Tests/Grand.Business.Common.Tests/Services/Audit/AuditServiceTests.cs @@ -75,6 +75,18 @@ public async Task GetEntries_RecordHistory_HoldsTheRecordsThatBelongToIt() CollectionAssert.AreEqual(new[] { note.Id, update.Id }, result.Select(x => x.Id).ToArray()); } + [TestMethod] + public async Task GetEntries_SeveralEntityTypes() + { + var permission = await Add("Permission", "p1", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc)); + var action = await Add("PermissionAction", "a1", new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc)); + await Add("Product", "x1", new DateTime(2026, 10, 3, 0, 0, 0, DateTimeKind.Utc)); + + var result = await _service.GetEntries(new AuditEntrySearch { EntityType = "Permission, PermissionAction" }); + + CollectionAssert.AreEqual(new[] { action.Id, permission.Id }, result.Select(x => x.Id).ToArray()); + } + [TestMethod] public async Task GetEntries_FiltersByEntityNames() { diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/HistoryPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/HistoryPopup.cshtml index fcfe719b01..200e9db31a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/HistoryPopup.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/AuditLog/HistoryPopup.cshtml @@ -7,7 +7,7 @@ @* Opened from the "Change history" button of an edit or list screen. Values are grid text nodes, never HTML; the full entry and the CSV export are one click away in the audit log. *@ - + + @Loc["Admin.Common.AddNew"] diff --git a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs index 18c1b86d12..df5962914c 100644 --- a/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/AuditLogController.cs @@ -156,6 +156,9 @@ private async Task PrepareLists(AuditLogListModel model) model.AvailableEntityTypes.Add(new SelectListItem(all, "")); foreach (var type in await auditService.GetEntityTypes()) model.AvailableEntityTypes.Add(new SelectListItem(type, type)); + //the types of one screen's history, opened in the log + if (!string.IsNullOrEmpty(model.EntityType) && model.AvailableEntityTypes.All(t => t.Value != model.EntityType)) + model.AvailableEntityTypes.Add(new SelectListItem(model.EntityType.Replace(",", ", "), model.EntityType)); model.AvailableSources.Add(new SelectListItem(all, "")); foreach (var source in Enum.GetValues()) From 3ccb2d876d88474d57cfc180dcc6f0a539394506 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 30 Oct 2026 07:47:04 +0100 Subject: [PATCH 6/6] Record a language import as one entry, and show resource edits in the language's history A language pack holds thousands of resources, and each new one was an Insert entry of its own. IAuditTracker.CountInserts counts the inserts of a type in a scope and records them as one bulk entry ("Import: language: Polish; inserted: 9842") linked to the language; a changed value stays an entry of its own. The import no longer writes a resource the file does not change. TranslationResource.LanguageId is the owner, so a resource edit shows in the language's change history. The history shows the details of an entry without changes. --- .../Localization/TranslationService.cs | 9 ++-- src/Core/Grand.Data/Audit/IAuditTracker.cs | 7 +++ .../Localization/TranslationResource.cs | 4 ++ .../Audit/AuditTracker.cs | 52 +++++++++++++++++++ .../Audit/AuditTrackerTests.cs | 40 ++++++++++++++ .../Models/AuditChangeTextTests.cs | 11 ++++ .../Controllers/LanguageController.cs | 9 +++- .../Models/AuditLog/AuditChangeText.cs | 3 ++ 8 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/Business/Grand.Business.Common/Services/Localization/TranslationService.cs b/src/Business/Grand.Business.Common/Services/Localization/TranslationService.cs index 1b082981f0..01f5d7ac13 100644 --- a/src/Business/Grand.Business.Common/Services/Localization/TranslationService.cs +++ b/src/Business/Grand.Business.Common/Services/Localization/TranslationService.cs @@ -220,10 +220,13 @@ public virtual async Task ImportResourcesFromXml(Language language, string xml) if (resource != null) { - resource.Name = resource.Name.ToLowerInvariant(); + //a resource the file does not change is not written + var area = Enum.TryParse(item.Area, out var areaEnum) ? areaEnum : resource.Area; + if (resource.Value == item.Value && resource.Area == area) + continue; + resource.Value = item.Value; - if (Enum.TryParse(item.Area, out var areaEnum)) - resource.Area = areaEnum; + resource.Area = area; await _translationRepository.UpdateAsync(resource); } else diff --git a/src/Core/Grand.Data/Audit/IAuditTracker.cs b/src/Core/Grand.Data/Audit/IAuditTracker.cs index edd47da671..a2f161cfc4 100644 --- a/src/Core/Grand.Data/Audit/IAuditTracker.cs +++ b/src/Core/Grand.Data/Audit/IAuditTracker.cs @@ -30,6 +30,13 @@ Task BeforeWriteAsync(string id, bool isInsert, Func(string operation, string filter) where T : BaseEntity; + /// + /// Records the inserts of made until the returned scope ends as one bulk entry with + /// their count (an import of thousands of records) instead of one entry each; updates and deletes stay single + /// entries. With , the entry shows in that record's change history. + /// + IDisposable CountInserts(string operation, string details, BaseEntity owner = null) where T : BaseEntity; + Task FlushAsync(); /// diff --git a/src/Core/Grand.Domain/Localization/TranslationResource.cs b/src/Core/Grand.Domain/Localization/TranslationResource.cs index 976d7804d2..51014a2b66 100644 --- a/src/Core/Grand.Domain/Localization/TranslationResource.cs +++ b/src/Core/Grand.Domain/Localization/TranslationResource.cs @@ -1,3 +1,5 @@ +using Grand.Domain.Audit; + namespace Grand.Domain.Localization; /// @@ -8,6 +10,8 @@ public class TranslationResource : BaseEntity /// /// Gets or sets the language identifier /// + [AuditReference(typeof(Language))] + [AuditOwner] public string LanguageId { get; set; } /// diff --git a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs index 9a1de4bd21..e190ab311a 100644 --- a/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs +++ b/src/Core/Grand.Infrastructure/Audit/AuditTracker.cs @@ -31,6 +31,7 @@ public class AuditTracker( ]; private readonly List _bulk = []; + private readonly Dictionary _countedInserts = new(); private readonly Dictionary, Task>>>> _loaders = new(); @@ -63,6 +64,15 @@ public async Task BeforeWriteAsync(string id, bool isInsert, return; if (writtenPaths is { Count: > 0 } && writtenPaths.All(IgnoredPaths.Contains)) return; + if (isInsert) + lock (_lock) + { + if (_countedInserts.TryGetValue(typeof(T), out var counted)) + { + _countedInserts[typeof(T)] = counted + 1; + return; + } + } var key = (typeof(T), id); bool flush; @@ -150,6 +160,48 @@ public void RecordBulk(string operation, string filter) where T : BaseEntity } } + public IDisposable CountInserts(string operation, string details, BaseEntity owner = null) + where T : BaseEntity + { + if (!IsActive) + return new Scope(null); + + lock (_lock) + { + _countedInserts[typeof(T)] = 0; + } + + return new Scope(() => + { + int count; + lock (_lock) + { + _countedInserts.Remove(typeof(T), out count); + } + + if (count == 0) + return; + lock (_lock) + { + _bulk.Add(new AuditEntry { + Kind = AuditKind.Bulk, + EntityType = typeof(T).Name, + Details = $"{operation}: {details}; inserted: {count}", + OwnerType = owner?.GetType().Name, + OwnerId = owner?.Id + }); + } + }); + } + + private sealed class Scope(Action end) : IDisposable + { + public void Dispose() + { + end?.Invoke(); + } + } + public async Task CompleteAsync() { await FlushAsync(); diff --git a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs index a1f3265eaa..e31c88545e 100644 --- a/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs +++ b/src/Tests/Grand.Infrastructure.Tests/Audit/AuditTrackerTests.cs @@ -320,6 +320,46 @@ public async Task Bulk_RecordedAsIs() Assert.AreEqual("op-1", entry.OperationId); } + [TestMethod] + public async Task CountedInserts_OneBulkEntryWithTheCount_UpdatesStaySingle() + { + _tracker.Activate(Actor); + Put("old", price: 10); + + using (_tracker.CountInserts("Import", "language: Polish", new TrackedThing { Id = "lang1" })) + { + foreach (var id in new[] { "n1", "n2", "n3" }) + { + Put(id); + await _tracker.BeforeWriteAsync(id, true, _access); + } + + await _tracker.BeforeWriteAsync("old", false, _access); + _store["old"].Price = 20; + } + + Put("after"); + await _tracker.BeforeWriteAsync("after", true, _access); + await _tracker.FlushAsync(); + + var bulk = Written.Single(e => e.Kind == AuditKind.Bulk); + Assert.AreEqual("Import: language: Polish; inserted: 3", bulk.Details); + Assert.AreEqual(nameof(TrackedThing), bulk.OwnerType); + Assert.AreEqual("lang1", bulk.OwnerId); + Assert.AreEqual("old", Written.Single(e => e.Kind == AuditKind.Update).EntityId); + Assert.AreEqual("after", Written.Single(e => e.Kind == AuditKind.Insert).EntityId); + } + + [TestMethod] + public void CountedInserts_Inactive_NothingRecorded() + { + using (_tracker.CountInserts("Import", "x")) + { + } + + Assert.AreEqual(0, Written.Count); + } + [TestMethod] public void ExcludedType_NotAudited() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs index abcd882188..4f9291c5d2 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Models/AuditChangeTextTests.cs @@ -167,6 +167,17 @@ public void NoteInItsOwnersHistory_SaysWhatItIs() Assert.AreEqual("Merchandise return note Called the customer", _text.Text(deleted)); } + [TestMethod] + public void ImportInTheLanguagesHistory_ShowsItsDetails() + { + var import = new AuditEntry { + Kind = AuditKind.Bulk, EntityType = "TranslationResource", OwnerType = "Language", OwnerId = "l1", + Details = "Import: language: Polish; inserted: 9842" + }; + + Assert.AreEqual("Translation resource: Import: language: Polish; inserted: 9842", _text.Text(import)); + } + [TestMethod] public void ValueNotFromAReference_KeepsParentheses() { diff --git a/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs b/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs index 8502b3bdb1..e991036d62 100644 --- a/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/LanguageController.cs @@ -1,4 +1,6 @@ using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Data.Audit; +using Grand.Domain.Localization; using Grand.Domain.Permissions; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Interfaces; @@ -274,7 +276,8 @@ public async Task ExportXml(string id) [PermissionAuthorizeAction(PermissionActionName.Import)] [HttpPost] - public async Task ImportXml(string id, IFormFile importxmlfile) + public async Task ImportXml(string id, IFormFile importxmlfile, + [FromServices] IAuditTracker auditTracker) { var language = await _languageService.GetLanguageById(id); if (language == null) @@ -287,7 +290,9 @@ public async Task ImportXml(string id, IFormFile importxmlfile) { using var sr = new StreamReader(importxmlfile.OpenReadStream(), Encoding.UTF8); var content = await sr.ReadToEndAsync(); - await _translationService.ImportResourcesFromXml(language, content); + //the new resources of a language pack are one entry with their count; changed ones stay single entries + using (auditTracker.CountInserts("Import", $"language: {language.Name}", language)) + await _translationService.ImportResourcesFromXml(language, content); } else { diff --git a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs index 3c880d12db..f531c76bde 100644 --- a/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs +++ b/src/Web/Grand.Web.Admin/Models/AuditLog/AuditChangeText.cs @@ -49,6 +49,9 @@ public string Text(string entityType, IEnumerable changes) public string Text(AuditEntry entry) { var text = Text(entry.EntityType, entry.Changes); + //an entry without changes (a bulk write, an event) tells what happened in its details + if (text == "" && !string.IsNullOrEmpty(entry.Details)) + text = Cut(entry.Details, TextLength); if (entry.OwnerType == null) return text;