From e487c59e85c015bc2a69bd57f6569b381d84f3c7 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 07:14:59 +0200 Subject: [PATCH 1/2] Pass an explicit context to the product attribute formatter ProductAttributeFormatter no longer reads IContextAccessor. FormatAttributes takes the CalculationContext the prices are rendered for, the language of the attribute names and the store host used for file download links. Callers pass the values they already hold: the storefront cart handlers their request (Host added to GetAddToCart, GetMiniShoppingCart and GetWishlist), the admin panel the request context with the customer whose cart is shown, PlaceOrder its command, and the message tokens the message's language and host. --- .../Products/ProductAttributeFormatter.cs | 60 +++----- .../Orders/PlaceOrderCommandHandler.cs | 6 +- .../GetAttributeCombinationTokensCommand.cs | 4 + .../Tokens/GetShoppingCartTokensCommand.cs | 8 +- .../Products/IProductAttributeFormatter.cs | 21 ++- .../Utilities/Checkout/PlaceOrderContainer.cs | 6 + .../DotLiquidDrops/LiquidObjectBuilder.cs | 16 ++- .../Services/Campaigns/CampaignService.cs | 12 +- ...ttributeCombinationTokensCommandHandler.cs | 2 +- .../GetShoppingCartTokensCommandHandler.cs | 8 +- .../Services/MessageProviderService.cs | 2 +- .../ProductAttributeFormatterTests.cs | 130 +++++++++++++----- ...etShoppingCartTokensCommandHandlerTests.cs | 51 +++++++ .../Controllers/ShoppingCartController.cs | 9 +- .../Controllers/BaseReportsController.cs | 3 +- .../Services/CustomerViewModelService.cs | 8 +- .../Services/OrderViewModelService.cs | 5 +- .../Services/ProductViewModelService.cs | 6 +- .../Controllers/ActionCartController.cs | 13 +- .../OutOfStockSubscriptionController.cs | 9 +- .../Controllers/ShoppingCartController.cs | 9 +- .../Controllers/WishlistController.cs | 3 +- .../ShoppingCart/GetAddToCartHandler.cs | 4 +- .../GetMiniShoppingCartHandler.cs | 3 +- .../ShoppingCart/GetMiniWishlistHandler.cs | 4 +- .../ShoppingCart/GetShoppingCartHandler.cs | 3 +- .../ShoppingCart/GetWishlistHandler.cs | 3 +- .../Models/ShoppingCart/GetAddToCart.cs | 5 + .../ShoppingCart/GetMiniShoppingCart.cs | 5 + .../Models/ShoppingCart/GetShoppingCart.cs | 2 +- .../Models/ShoppingCart/GetWishlist.cs | 5 + 31 files changed, 296 insertions(+), 129 deletions(-) create mode 100644 src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs index 3aa63362ab..b77651d6e0 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs @@ -2,12 +2,12 @@ using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Catalog; using Grand.Domain.Common; -using Grand.Domain.Customers; +using Grand.Domain.Localization; using Grand.Domain.Orders; -using Grand.Domain.Tax; -using Grand.Infrastructure; +using Grand.Domain.Stores; using Grand.SharedKernel.Extensions; using System.Net; @@ -23,16 +23,13 @@ public class ProductAttributeFormatter : IProductAttributeFormatter private readonly IProductAttributeService _productAttributeService; private readonly IProductService _productService; private readonly ITaxService _taxService; - private readonly IContextAccessor _contextAccessor; - public ProductAttributeFormatter(IContextAccessor contextAccessor, - IProductAttributeService productAttributeService, + public ProductAttributeFormatter(IProductAttributeService productAttributeService, ITaxService taxService, IPriceFormatter priceFormatter, IPricingService priceCalculationService, IProductService productService) { - _contextAccessor = contextAccessor; _productAttributeService = productAttributeService; _taxService = taxService; _priceFormatter = priceFormatter; @@ -45,19 +42,9 @@ public ProductAttributeFormatter(IContextAccessor contextAccessor, /// /// Product /// Attributes - /// Attributes - public virtual Task FormatAttributes(Product product, IList customAttributes) - { - var customer = _contextAccessor.WorkContext.CurrentCustomer; - return FormatAttributes(product, customAttributes, customer); - } - - /// - /// Formats attributes - /// - /// Product - /// Attributes - /// Customer + /// Customer, store, currency and tax display the prices are rendered for (read only when renderPrices is true) + /// Language of the attribute names + /// Store address used for file download links (read only when allowHyperlinks is true) /// Separator /// A value indicating whether to encode (HTML) values /// A value indicating whether to render prices @@ -67,7 +54,8 @@ public virtual Task FormatAttributes(Product product, IListShow in admin /// Attributes public virtual async Task FormatAttributes(Product product, IList customAttributes, - Customer customer, string separator = "
", bool htmlEncode = true, bool renderPrices = true, + CalculationContext context, Language language, DomainHost host, + string separator = "
", bool htmlEncode = true, bool renderPrices = true, bool renderProductAttributes = true, bool renderGiftVoucherAttributes = true, bool allowHyperlinks = true, bool showInAdmin = false) { @@ -76,17 +64,12 @@ public virtual async Task FormatAttributes(Product product, IList(SystemCustomerFieldNames.LanguageId); - - if (string.IsNullOrEmpty(langId)) - langId = ""; + var langId = language?.Id ?? ""; //attributes if (renderProductAttributes) { - result = await PrepareFormattedAttribute(product, customAttributes, customer, langId, separator, + result = await PrepareFormattedAttribute(product, customAttributes, context, langId, host, separator, htmlEncode, renderPrices, allowHyperlinks, showInAdmin); if (product.ProductTypeId == ProductType.BundledProduct) @@ -106,8 +89,8 @@ public virtual async Task FormatAttributes(Product product, IList x.Name, langId)) : p1.GetTranslation(x => x.Name, langId))}"); - var formattedAttribute = await PrepareFormattedAttribute(p1, customAttributes, customer, langId, - separator, htmlEncode, + var formattedAttribute = await PrepareFormattedAttribute(p1, customAttributes, context, langId, + host, separator, htmlEncode, renderPrices, allowHyperlinks, showInAdmin); if (formattedAttribute.Length > 0) { @@ -151,11 +134,10 @@ public virtual async Task FormatAttributes(Product product, IList PrepareFormattedAttribute(Product product, - IList customAttributes, Customer customer, string langId, - string separator, bool htmlEncode, bool renderPrices, + IList customAttributes, CalculationContext context, string langId, + DomainHost host, string separator, bool htmlEncode, bool renderPrices, bool allowHyperlinks, bool showInAdmin) { - var priceCustomer = customer ?? _contextAccessor.WorkContext.CurrentCustomer; var result = new StringBuilder(); var attributes = product.ParseProductAttributeMappings(customAttributes); for (var i = 0; i < attributes.Count; i++) @@ -197,7 +179,7 @@ private async Task PrepareFormattedAttribute(Product product, if (allowHyperlinks) { var downloadLink = - $"{_contextAccessor.StoreContext.CurrentHost.Url.TrimEnd('/')}/download/getfileupload/?downloadId={downloadGuid}"; + $"{host.Url.TrimEnd('/')}/download/getfileupload/?downloadId={downloadGuid}"; attributeText = $"{attribute.GetTranslation(a => a.TextPrompt, langId)}"; } @@ -236,25 +218,23 @@ private async Task PrepareFormattedAttribute(Product product, { var attributeValuePriceAdjustment = await _pricingService.GetProductAttributeValuePriceAdjustment(attributeValue, - priceCustomer, _contextAccessor.StoreContext.CurrentStore, - _contextAccessor.WorkContext.WorkingCurrency); + context.Customer, context.Store, context.Currency); var (priceAdjustmentBase, _) = await _taxService.GetProductPrice(product, attributeValuePriceAdjustment, - _contextAccessor.WorkContext.TaxDisplayType == TaxDisplayType.IncludingTax, - priceCustomer, _contextAccessor.StoreContext.CurrentStore); + context.IncludingTax, context.Customer, context.Store); switch (priceAdjustmentBase) { case > 0: { var priceAdjustmentStr = _priceFormatter.FormatPrice(priceAdjustmentBase, - _contextAccessor.WorkContext.WorkingCurrency); + context.Currency); formattedAttribute += $" [+{priceAdjustmentStr}]"; break; } case < 0: { var priceAdjustmentStr = _priceFormatter.FormatPrice(-priceAdjustmentBase, - _contextAccessor.WorkContext.WorkingCurrency); + context.Currency); formattedAttribute += $" [-{priceAdjustmentStr}]"; break; } diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs index 6633308e16..1d68f81fec 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -386,7 +386,8 @@ protected virtual async Task PreparePlaceOrderDetails(Place var details = new PlaceOrderContainer { Context = command.Context, Customer = command.Context.Customer, - Store = command.Context.Store + Store = command.Context.Store, + Host = command.Host }; if (details.Customer == null) throw new ArgumentException("Customer is not set"); @@ -651,7 +652,8 @@ protected virtual async Task PrepareOrderItem(ShoppingCartItem sc, Pr details.AppliedDiscounts.All(x => x.DiscountId != disc.DiscountId))) details.AppliedDiscounts.Add(disc); var attributeDescription = - await _productAttributeFormatter.FormatAttributes(product, sc.Attributes, details.Customer); + await _productAttributeFormatter.FormatAttributes(product, sc.Attributes, details.Context, + details.Language, details.Host); var itemWeight = await GetShoppingCartItemWeight(sc); var warehouseId = !string.IsNullOrEmpty(sc.WarehouseId) ? sc.WarehouseId diff --git a/src/Business/Grand.Business.Core/Commands/Messages/Tokens/GetAttributeCombinationTokensCommand.cs b/src/Business/Grand.Business.Core/Commands/Messages/Tokens/GetAttributeCombinationTokensCommand.cs index 9aa1fdebe6..af8caf5d4c 100644 --- a/src/Business/Grand.Business.Core/Commands/Messages/Tokens/GetAttributeCombinationTokensCommand.cs +++ b/src/Business/Grand.Business.Core/Commands/Messages/Tokens/GetAttributeCombinationTokensCommand.cs @@ -1,5 +1,7 @@ using Grand.Business.Core.Utilities.Messages.DotLiquidDrops; using Grand.Domain.Catalog; +using Grand.Domain.Localization; +using Grand.Domain.Stores; using Grand.Mediator; namespace Grand.Business.Core.Commands.Messages.Tokens; @@ -8,4 +10,6 @@ public class GetAttributeCombinationTokensCommand : IRequest { - public Customer Customer { get; set; } - public Store Store { get; set; } + public CalculationContext Context { get; set; } + public DomainHost Host { get; set; } public Language Language { get; set; } } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs index 300d1255a5..dedf8e79f3 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs @@ -1,6 +1,8 @@ +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Catalog; using Grand.Domain.Common; -using Grand.Domain.Customers; +using Grand.Domain.Localization; +using Grand.Domain.Stores; namespace Grand.Business.Core.Interfaces.Catalog.Products; @@ -14,15 +16,9 @@ public interface IProductAttributeFormatter /// /// Product /// Attributes - /// Attributes - Task FormatAttributes(Product product, IList customAttributes); - - /// - /// Formats attributes - /// - /// Product - /// Attributes - /// Customer + /// Customer, store, currency and tax display the prices are rendered for (read only when renderPrices is true) + /// Language of the attribute names + /// Store address used for file download links (read only when allowHyperlinks is true) /// Separator /// A value indicating whether to encode (HTML) values /// A value indicating whether to render prices @@ -32,7 +28,8 @@ public interface IProductAttributeFormatter /// Show in admin /// Attributes Task FormatAttributes(Product product, IList customAttributes, - Customer customer, string separator = "
", bool htmlEncode = true, bool renderPrices = true, + CalculationContext context, Language language, DomainHost host, + string separator = "
", bool htmlEncode = true, bool renderPrices = true, bool renderProductAttributes = true, bool renderGiftVoucherAttributes = true, bool allowHyperlinks = true, bool showInAdmin = false); -} \ No newline at end of file +} diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index eb6bff6aa8..e60774f26a 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -21,6 +21,12 @@ public class PlaceOrderContainer public Customer Customer { get; set; } public Store Store { get; set; } public Language Language { get; set; } + + /// + /// Store address used for file download links in the attribute descriptions + /// + public DomainHost Host { get; set; } + public Currency Currency { get; set; } public string AffiliateId { get; set; } public TaxDisplayType TaxDisplayType { get; set; } diff --git a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs index 86bd80f41e..92ed2f3cac 100644 --- a/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs +++ b/src/Business/Grand.Business.Core/Utilities/Messages/DotLiquidDrops/LiquidObjectBuilder.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Commands.Messages.Tokens; using Grand.Business.Core.Extensions; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Blogs; using Grand.Domain.Catalog; using Grand.Domain.Customers; @@ -122,18 +123,18 @@ public LiquidObjectBuilder AddCustomerTokens(Customer customer, Store store, Dom return this; } - public LiquidObjectBuilder AddShoppingCartTokens(Customer customer, Store store, Language language) + public LiquidObjectBuilder AddShoppingCartTokens(CalculationContext context, DomainHost host, Language language) { _chain.Add(async liquidObject => { var liquidShoppingCart = await _mediator.Send(new GetShoppingCartTokensCommand { - Customer = customer, - Language = language, - Store = store + Context = context, + Host = host, + Language = language }); liquidObject.ShoppingCart = liquidShoppingCart; - await _mediator.EntityTokensAdded(customer, liquidShoppingCart, liquidObject); + await _mediator.EntityTokensAdded(context.Customer, liquidShoppingCart, liquidObject); }); return this; } @@ -231,12 +232,13 @@ public LiquidObjectBuilder AddProductTokens(Product product, Language language, return this; } - public LiquidObjectBuilder AddAttributeCombinationTokens(Product product, ProductAttributeCombination combination) + public LiquidObjectBuilder AddAttributeCombinationTokens(Product product, ProductAttributeCombination combination, + Language language, DomainHost host) { _chain.Add(async liquidObject => { var liquidAttributeCombination = await _mediator.Send(new GetAttributeCombinationTokensCommand - { Product = product, Combination = combination }); + { Product = product, Combination = combination, Language = language, Host = host }); liquidObject.AttributeCombination = liquidAttributeCombination; await _mediator.EntityTokensAdded(combination, liquidAttributeCombination, liquidObject); }); diff --git a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs index 2ec54e947c..40a96f7c67 100644 --- a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs +++ b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs @@ -2,12 +2,14 @@ using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Business.Core.Interfaces.Marketing.Campaigns; using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Checkout; using Grand.Business.Core.Utilities.Messages.DotLiquidDrops; using Grand.Data; using Grand.Domain; using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Messages; +using Grand.Domain.Stores; using Grand.Infrastructure; using Grand.Infrastructure.Extensions; using Grand.Mediator; @@ -256,7 +258,8 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emai if (customer != null) builder.AddCustomerTokens(customer, store, contextAccessor.StoreContext.CurrentHost, language) - .AddShoppingCartTokens(customer, store, language); + .AddShoppingCartTokens(ShoppingCartContext(customer, store), + contextAccessor.StoreContext.CurrentHost, language); var email = new QueuedEmail(); @@ -288,6 +291,10 @@ await InsertCampaignHistory(new CampaignHistory { return totalEmailsSent; } + // TODO M1 PR 5: the currency and tax display still come from the request that sends the campaign + private CalculationContext ShoppingCartContext(Customer customer, Store store) => + new(customer, store, contextAccessor.WorkContext.WorkingCurrency, contextAccessor.WorkContext.TaxDisplayType); + /// /// Sends a campaign to specified email /// @@ -310,7 +317,8 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAcco var customer = customerRepository.Table.FirstOrDefault(x => x.Email == email.ToLowerInvariant()); if (customer != null) builder.AddCustomerTokens(customer, store, contextAccessor.StoreContext.CurrentHost, language) - .AddShoppingCartTokens(customer, store, language); + .AddShoppingCartTokens(ShoppingCartContext(customer, store), contextAccessor.StoreContext.CurrentHost, + language); var liquidObject = await builder.BuildAsync(); var body = LiquidExtensions.Render(liquidObject, campaign.Body); diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetAttributeCombinationTokensCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetAttributeCombinationTokensCommandHandler.cs index edd1488158..50698c1ef2 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetAttributeCombinationTokensCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetAttributeCombinationTokensCommandHandler.cs @@ -21,7 +21,7 @@ public async Task Handle(GetAttributeCombinationToke { var liquidAttributeCombination = new LiquidAttributeCombination(request.Combination) { Formatted = await _productAttributeFormatter.FormatAttributes(request.Product, - request.Combination.Attributes, null, renderPrices: false), + request.Combination.Attributes, null, request.Language, request.Host, renderPrices: false), SKU = request.Product.FormatSku(request.Combination.Attributes) }; return liquidAttributeCombination; diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs index 2b1e0827d7..485c21986b 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs @@ -25,14 +25,14 @@ public GetShoppingCartTokensCommandHandler( public async Task Handle(GetShoppingCartTokensCommand request, CancellationToken cancellationToken) { - var liquidShoppingCart = new LiquidShoppingCart(request.Customer); + var liquidShoppingCart = new LiquidShoppingCart(request.Context.Customer); await ProductList(); async Task ProductList() { var attributeDescription = string.Empty; var pictureUrl = string.Empty; - foreach (var item in request.Customer.ShoppingCartItems) + foreach (var item in request.Context.Customer.ShoppingCartItems) { var product = await _productService.GetProductById(item.ProductId); @@ -41,13 +41,13 @@ async Task ProductList() product.ProductPictures.OrderByDescending(p => p.IsDefault) .ThenBy(p => p.DisplayOrder) .FirstOrDefault()?.PictureId, 100, - storeLocation: request.Store.Url); + storeLocation: request.Context.Store.Url); //attributes if (item.Attributes != null && item.Attributes.Any()) attributeDescription = await _productAttributeFormatter.FormatAttributes(product, item.Attributes, - request.Customer); + request.Context, request.Language, request.Host); var result = new LiquidShoppingCartItem(product, attributeDescription, pictureUrl, item, request.Language); diff --git a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs index 71f990e6eb..4778a0c1ec 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageProviderService.cs @@ -1328,7 +1328,7 @@ public virtual async Task SendQuantityBelowStoreOwnerMessage(Product produc var builder = new LiquidObjectBuilder(_mediator); builder.AddStoreTokens(store, language, emailAccount) .AddProductTokens(product, language, store, CurrentHost) - .AddAttributeCombinationTokens(product, combination); + .AddAttributeCombinationTokens(product, combination, language, CurrentHost); var liquidObject = await builder.BuildAsync(); //event notification await _mediator.MessageTokensAdded(messageTemplate, liquidObject); diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs index 182c421401..73dda0e61a 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs @@ -1,17 +1,15 @@ using Grand.Business.Catalog.Services.Products; -using Grand.Business.Common.Services.Security; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; using Grand.Domain.Stores; -using Grand.Infrastructure; -using Grand.Infrastructure.Configuration; -using Grand.Mediator; +using Grand.Domain.Tax; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -20,40 +18,47 @@ namespace Grand.Business.Catalog.Tests.Services.Products; [TestClass] public class ProductAttributeFormatterTests { - private IAclService _aclServiceMock; - private Mock _mediatorMock; private Mock _priceFormatterMock; private Mock _pricingServiceMock; private ProductAttributeFormatter _productAttributeFormatter; private Mock _productAttributeServiceMock; private Mock _productServiceMock; private Mock _taxServiceMock; - private Mock _translationServiceMock; - private Mock _workContextMock; + private CalculationContext _context; + private Language _language; + private DomainHost _host; [TestInitialize] public void InitializeTests() { - _workContextMock = new Mock(); - _workContextMock.Setup(c => c.StoreContext.CurrentStore).Returns(() => new Store { Id = "" }); - _workContextMock.Setup(c => c.WorkContext.CurrentCustomer).Returns(() => new Customer()); _productAttributeServiceMock = new Mock(); _productAttributeServiceMock.Setup(x => x.GetProductAttributeById(It.IsAny())) - .Returns(Task.FromResult(new ProductAttribute { Name = "test" })); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(x => x.GetResource("GiftVoucherAttribute.From.Virtual")) - .Returns("Name {0}, Email {1}"); - _translationServiceMock.Setup(x => x.GetResource("GiftVoucherAttribute.For.Virtual")) - .Returns("RecName {0}, RecEmail {1}"); + .Returns(Task.FromResult(new ProductAttribute { + Name = "test", + Locales = { new TranslationEntity { LanguageId = "pl", LocaleKey = "Name", LocaleValue = "test-pl" } } + })); _taxServiceMock = new Mock(); _priceFormatterMock = new Mock(); _pricingServiceMock = new Mock(); _productServiceMock = new Mock(); - _mediatorMock = new Mock(); - _aclServiceMock = new AclService(new AccessControlConfig()); - _productAttributeFormatter = new ProductAttributeFormatter(_workContextMock.Object, - _productAttributeServiceMock.Object, + _productAttributeFormatter = new ProductAttributeFormatter(_productAttributeServiceMock.Object, _taxServiceMock.Object, _priceFormatterMock.Object, _pricingServiceMock.Object, _productServiceMock.Object); + + _context = new CalculationContext(new Customer { Id = "c1" }, new Store { Id = "s1" }, + new Currency { CurrencyCode = "EUR" }, TaxDisplayType.IncludingTax); + _language = new Language { Id = "en" }; + _host = new DomainHost { Url = "https://shop.example/" }; + } + + private static Product ProductWithCheckboxValue(double priceAdjustment = 0) + { + var product = new Product(); + var productAttributeMapping = new ProductAttributeMapping + { Id = "1", AttributeControlTypeId = AttributeControlType.Checkboxes }; + productAttributeMapping.ProductAttributeValues.Add(new ProductAttributeValue + { Id = "1", Name = "aa", PriceAdjustment = priceAdjustment }); + product.ProductAttributeMappings.Add(productAttributeMapping); + return product; } [TestMethod] @@ -62,7 +67,8 @@ public async Task FormatAttributesTest_EmptyCustomAttribute() //Arrange var product = new Product(); //Act - var result = await _productAttributeFormatter.FormatAttributes(product, new List()); + var result = await _productAttributeFormatter.FormatAttributes(product, new List(), + _context, _language, _host); //Assert Assert.AreEqual("", result); } @@ -79,7 +85,7 @@ public async Task FormatAttributesTest_GiftVoucher() new() { Key = "SenderName", Value = "Will" }, new() { Key = "SenderEmail", Value = "Will@will.com" }, new() { Key = "Message", Value = "" } - }); + }, _context, _language, _host); //Assert Assert.AreEqual("Will <Will@will.com>
John <John@john.com>", result); } @@ -88,16 +94,76 @@ public async Task FormatAttributesTest_GiftVoucher() public async Task FormatAttributesTest_CustomAttribute() { //Arrange - var product = new Product(); - var productAttributeMapping = new ProductAttributeMapping - { Id = "1", AttributeControlTypeId = AttributeControlType.Checkboxes }; - productAttributeMapping.ProductAttributeValues.Add(new ProductAttributeValue { Id = "1", Name = "aa" }); - product.ProductAttributeMappings.Add(productAttributeMapping); + var product = ProductWithCheckboxValue(); //Act var result = await _productAttributeFormatter.FormatAttributes(product, new List { new() { Key = "1", Value = "1" } - }); + }, _context, _language, _host); //Assert Assert.AreEqual("test: aa", result); } -} \ No newline at end of file + + [TestMethod] + public async Task FormatAttributes_TranslatesNamesIntoTheGivenLanguage() + { + //Arrange + var product = ProductWithCheckboxValue(); + //Act + var result = await _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = "1" } + }, _context, new Language { Id = "pl" }, _host); + //Assert + Assert.AreEqual("test-pl: aa", result); + } + + [TestMethod] + public async Task FormatAttributes_PricesTheAdjustmentForTheGivenContext() + { + //Arrange + var product = ProductWithCheckboxValue(5); + _pricingServiceMock.Setup(x => x.GetProductAttributeValuePriceAdjustment(It.IsAny(), + _context.Customer, _context.Store, _context.Currency)) + .ReturnsAsync(5); + _taxServiceMock.Setup(x => x.GetProductPrice(product, 5, true, _context.Customer, _context.Store)) + .ReturnsAsync((6, 20)); + _priceFormatterMock.Setup(x => x.FormatPrice(6, _context.Currency)).Returns("6 EUR"); + //Act + var result = await _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = "1" } + }, _context, _language, _host); + //Assert + Assert.AreEqual("test: aa [+6 EUR]", result); + } + + [TestMethod] + public async Task FormatAttributes_WithoutPrices_DoesNotNeedAContext() + { + //Arrange + var product = ProductWithCheckboxValue(5); + //Act + var result = await _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = "1" } + }, null, _language, _host, renderPrices: false); + //Assert + Assert.AreEqual("test: aa", result); + _pricingServiceMock.VerifyNoOtherCalls(); + } + + [TestMethod] + public async Task FormatAttributes_FileUpload_LinksToTheGivenHost() + { + //Arrange + var product = new Product(); + product.ProductAttributeMappings.Add(new ProductAttributeMapping + { Id = "1", AttributeControlTypeId = AttributeControlType.FileUpload, TextPrompt = "file" }); + var downloadGuid = Guid.NewGuid(); + //Act + var result = await _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = downloadGuid.ToString() } + }, _context, _language, _host); + //Assert + Assert.AreEqual( + $"test: file", + result); + } +} diff --git a/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs b/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs new file mode 100644 index 0000000000..0d2c2b513f --- /dev/null +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs @@ -0,0 +1,51 @@ +using Grand.Business.Core.Commands.Messages.Tokens; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Business.Core.Utilities.Checkout; +using Grand.Business.Messages.Commands.Handlers.Messages; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Localization; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Messages.Tests.Commands; + +[TestClass] +public class GetShoppingCartTokensCommandHandlerTests +{ + [TestMethod] + public async Task Handle_FormatsAttributesForTheMessageContextLanguageAndHost() + { + //Arrange + var product = new Product { Id = "p1" }; + var productService = new Mock(); + productService.Setup(x => x.GetProductById("p1", false)).ReturnsAsync(product); + var formatter = new Mock(); + var customer = new Customer(); + var item = new ShoppingCartItem { ProductId = "p1" }; + item.Attributes.Add(new CustomAttribute { Key = "1", Value = "1" }); + customer.ShoppingCartItems.Add(item); + var command = new GetShoppingCartTokensCommand { + Context = new CalculationContext(customer, new Store { Id = "s1" }, new Currency(), + TaxDisplayType.ExcludingTax), + Language = new Language { Id = "pl" }, + Host = new DomainHost { Url = "https://shop.example/" } + }; + var handler = new GetShoppingCartTokensCommandHandler(new Mock().Object, + productService.Object, formatter.Object); + + //Act + await handler.Handle(command, CancellationToken.None); + + //Assert + formatter.Verify(x => x.FormatAttributes(product, item.Attributes, command.Context, command.Language, + command.Host, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs index 573f7193e7..85b173acd9 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs @@ -13,6 +13,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; +using Grand.Web.Common.Extensions; namespace Grand.Web.Admin.Controllers; @@ -112,7 +113,9 @@ public async Task GetCartDetails(string customerId) ProductId = sci.ProductId, Quantity = sci.Quantity, ProductName = product.Name, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, customer), + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = _priceFormatter.FormatPrice((await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, customer, priceStore, @@ -184,7 +187,9 @@ public async Task GetWishlistDetails(string customerId) ProductId = sci.ProductId, Quantity = sci.Quantity, ProductName = product.Name, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, customer), + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = _priceFormatter.FormatPrice((await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, customer, priceStore, diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs index 0e86766fcf..ffa261a35b 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseReportsController.cs @@ -341,7 +341,8 @@ public virtual async Task LowStockReportList(DataSourceRequest co Id = product.Id, Name = product.Name, Attributes = await productAttributeFormatter.FormatAttributes(product, combination.Attributes, - contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false), + contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, + contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false), ManageInventoryMethod = enumTranslationService.GetTranslationEnum(product.ManageInventoryMethodId), StockQuantity = combination.StockQuantity, Published = product.Published diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index 4aab4bf264..7238c7b697 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -874,7 +874,9 @@ public virtual async Task> PrepareShoppingCartItemM ProductId = sci.ProductId, Quantity = sci.Quantity, ProductName = product.Name, - AttributeInfo = await productAttributeFormatter.FormatAttributes(product, sci.Attributes, customer), + AttributeInfo = await productAttributeFormatter.FormatAttributes(product, sci.Attributes, + _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = priceFormatter.FormatPrice(price, _contextAccessor.WorkContext.WorkingCurrency), UnitPriceValue = price, Total = priceFormatter.FormatPrice((await taxService.GetProductPrice(product, @@ -1107,7 +1109,9 @@ await outOfStockSubscriptionService.GetAllSubscriptionsByCustomerId(customerId, ProductName = product != null ? product.Name : "Unknown", AttributeDescription = x.Attributes != null || !x.Attributes!.Any() ? "" - : await productAttributeFormatter.FormatAttributes(product, x.Attributes), + : await productAttributeFormatter.FormatAttributes(product, x.Attributes, + _contextAccessor.CalculationContext(), _contextAccessor.WorkContext.WorkingLanguage, + _contextAccessor.StoreContext.CurrentHost), CreatedOn = _dateTimeService.ConvertToUserTime(x.CreatedOnUtc, DateTimeKind.Utc) }; items.Add(m); diff --git a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs index 3ac50711c3..6ca2c68334 100644 --- a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs @@ -34,6 +34,7 @@ using System.Net; using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; using Grand.Business.Core.Queries.Checkout.Orders; +using Grand.Web.Common.Extensions; namespace Grand.Web.AdminShared.Services; @@ -1160,7 +1161,9 @@ await _shoppingCartValidator.GetShoppingCartItemGiftVoucherWarnings(customer, pr if (warnings.Count == 0) { var attributeDescription = - await _productAttributeFormatter.FormatAttributes(product, customattributes, customer); + await _productAttributeFormatter.FormatAttributes(product, customattributes, + _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost); //save item var orderItem = new OrderItem { diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 0de14ba26a..1bff05291b 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -2007,7 +2007,8 @@ public virtual async Task> foreach (var x in product.ProductAttributeCombinations) { var attributes = await productAttributeFormatter.FormatAttributes(product, x.Attributes, - contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false, true, true); + contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, + contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false, true, true); var pacModel = new ProductModel.ProductAttributeCombinationModel { Id = x.Id, ProductId = product.Id, @@ -2058,7 +2059,8 @@ public virtual async Task PrepareProductAttrib model.WarehouseInventoryModels = wim; model.ProductId = product.Id; model.Attributes = await productAttributeFormatter.FormatAttributes(product, combination.Attributes, - contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false); + contextAccessor.CalculationContext(), contextAccessor.WorkContext.WorkingLanguage, + contextAccessor.StoreContext.CurrentHost, "
", true, true, true, false); if (model.UseMultipleWarehouses) foreach (var _winv in combination.WarehouseInventory) { diff --git a/src/Web/Grand.Web/Controllers/ActionCartController.cs b/src/Web/Grand.Web/Controllers/ActionCartController.cs index 5eeac268e1..6bb4f3b5ea 100644 --- a/src/Web/Grand.Web/Controllers/ActionCartController.cs +++ b/src/Web/Grand.Web/Controllers/ActionCartController.cs @@ -241,7 +241,8 @@ await _shoppingCartService.FindShoppingCartItem(cart, model.ShoppingCartTypeId, Currency = _contextAccessor.WorkContext.WorkingCurrency, Store = _contextAccessor.StoreContext.CurrentStore, Language = _contextAccessor.WorkContext.WorkingLanguage, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType + TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Host = _contextAccessor.StoreContext.CurrentHost }); //added to the cart/wishlist @@ -300,7 +301,8 @@ await _shoppingCartService.FindShoppingCartItem(cart, model.ShoppingCartTypeId, Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }) : null; @@ -433,6 +435,7 @@ public virtual async Task> AddProductDetails(Pr Store = _contextAccessor.StoreContext.CurrentStore, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Host = _contextAccessor.StoreContext.CurrentHost, Duration = duration, Parameter = parameter, ReservationId = model.Reservation, @@ -496,7 +499,8 @@ public virtual async Task> AddProductDetails(Pr Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }) : null; @@ -778,7 +782,8 @@ await auctionService.NewBid(customer, product, _contextAccessor.StoreContext.Cur Currency = _contextAccessor.WorkContext.WorkingCurrency, Store = _contextAccessor.StoreContext.CurrentStore, Language = _contextAccessor.WorkContext.WorkingLanguage, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType + TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Host = _contextAccessor.StoreContext.CurrentHost }); return Json(new { diff --git a/src/Web/Grand.Web/Controllers/OutOfStockSubscriptionController.cs b/src/Web/Grand.Web/Controllers/OutOfStockSubscriptionController.cs index 0e0d8ed204..06ef2772a2 100644 --- a/src/Web/Grand.Web/Controllers/OutOfStockSubscriptionController.cs +++ b/src/Web/Grand.Web/Controllers/OutOfStockSubscriptionController.cs @@ -13,6 +13,7 @@ using Grand.Web.Models.Catalog; using Grand.Mediator; using Microsoft.AspNetCore.Mvc; +using Grand.Web.Common.Extensions; namespace Grand.Web.Controllers; @@ -181,7 +182,9 @@ public virtual async Task SubscribePopup(ProductModel model) Attributes = attributes, AttributeInfo = !attributes.Any() ? "" - : await _productAttributeFormatter.FormatAttributes(product, attributes), + : await _productAttributeFormatter.FormatAttributes(product, attributes, + _contextAccessor.CalculationContext(), _contextAccessor.WorkContext.WorkingLanguage, + _contextAccessor.StoreContext.CurrentHost), StoreId = _contextAccessor.StoreContext.CurrentStore.Id, WarehouseId = warehouseId }; @@ -222,7 +225,9 @@ public virtual async Task CustomerSubscriptions(int? pageNumber) ProductName = product.GetTranslation(x => x.Name, _contextAccessor.WorkContext.WorkingLanguage.Id), AttributeDescription = !subscription.Attributes.Any() ? "" - : await _productAttributeFormatter.FormatAttributes(product, subscription.Attributes), + : await _productAttributeFormatter.FormatAttributes(product, subscription.Attributes, + _contextAccessor.CalculationContext(), _contextAccessor.WorkContext.WorkingLanguage, + _contextAccessor.StoreContext.CurrentHost), SeName = product.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }; model.Subscriptions.Add(subscriptionModel); diff --git a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs index 775b737b8a..2a2e609fc4 100644 --- a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs @@ -132,7 +132,8 @@ public async Task> SidebarShoppingCart() Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }); return Json(model); } @@ -380,7 +381,8 @@ await _shoppingCartService.DeleteShoppingCartItem(_contextAccessor.WorkContext.C Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }); if (!model.ShoppingCartPage) return Json(new { @@ -429,7 +431,8 @@ public virtual async Task ChangeTypeCartItem(ChangeTypeCartItemMo Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }); var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); diff --git a/src/Web/Grand.Web/Controllers/WishlistController.cs b/src/Web/Grand.Web/Controllers/WishlistController.cs index 199bc649e7..7e9a94f999 100644 --- a/src/Web/Grand.Web/Controllers/WishlistController.cs +++ b/src/Web/Grand.Web/Controllers/WishlistController.cs @@ -100,7 +100,8 @@ public virtual async Task> Index(Guid? customerGuid) Currency = _contextAccessor.WorkContext.WorkingCurrency, Store = _contextAccessor.StoreContext.CurrentStore, IsEditable = !customerGuid.HasValue, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType + TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Host = _contextAccessor.StoreContext.CurrentHost }); return View(model); diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs index c5a80f3bc4..ed6866dd6e 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs @@ -68,7 +68,9 @@ public async Task Handle(GetAddToCart request, CancellationToken { var model = new AddToCartModel { AttributeDescription = - await _productAttributeFormatter.FormatAttributes(request.Product, request.Attributes), + await _productAttributeFormatter.FormatAttributes(request.Product, request.Attributes, + new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType), + request.Language, request.Host), ProductSeName = request.Product.GetSeName(request.Language.Id), CartType = request.CartType, ProductId = request.Product.Id, diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs index 40fb640ec8..dbc62cffa8 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs @@ -152,7 +152,8 @@ public async Task Handle(GetMiniShoppingCart request, ProductSeName = sename, ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes) + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, context, + request.Language, request.Host) }; if (product.ProductTypeId == ProductType.Reservation) { diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs index 39f4fb03ed..22dc7d1bb5 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs @@ -10,6 +10,7 @@ using Grand.Domain.Orders; using Grand.Domain.Tax; using Grand.Infrastructure; +using Grand.Web.Common.Extensions; using Grand.Web.Extensions; using Grand.Web.Features.Models.ShoppingCart; using Grand.Web.Models.Media; @@ -86,7 +87,8 @@ public async Task Handle(GetMiniWishlist request, Cancellatio ProductSeName = sename, ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes) + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + _contextAccessor.CalculationContext(), request.Language, _contextAccessor.StoreContext.CurrentHost) }; //unit prices diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 82712141ea..2b37c7898c 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -364,7 +364,8 @@ private async Task PrepareCartItems(ShoppingCartModel model, GetShoppingCart req ProductSeName = sename, ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes), + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + CalculationContextOf(request), request.Language, request.Host), AllowItemEditing = _shoppingCartSettings.AllowCartItemEditing && product.VisibleIndividually }; diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs index 472408f59a..bfcda1ccd3 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs @@ -123,7 +123,8 @@ public async Task Handle(GetWishlist request, CancellationToken c ProductSeName = sename, ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, - AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes), + AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, + CalculationContextOf(request), request.Language, request.Host), AllowItemEditing = _shoppingCartSettings.AllowCartItemEditing && product.VisibleIndividually }; diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs index 87b258028f..905283d95c 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs @@ -18,6 +18,11 @@ public class GetAddToCart : IRequest public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } + + /// + /// Store address of the request, used for the download links of product attributes + /// + public DomainHost Host { get; set; } public ShoppingCartItem ShoppingCartItem { get; set; } public TaxDisplayType TaxDisplayType { get; set; } public int Quantity { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs index 5c1967976e..ca3b8190a3 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs @@ -14,5 +14,10 @@ public class GetMiniShoppingCart : IRequest public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } + + /// + /// Store address of the request, used for the download links of product attributes + /// + public DomainHost Host { get; set; } public TaxDisplayType TaxDisplayType { get; set; } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs index ce49bd7fce..d225058eb7 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs @@ -17,7 +17,7 @@ public class GetShoppingCart : IRequest public Domain.Stores.Store Store { get; set; } /// - /// Store address of the request, used for the download links of checkout attributes + /// Store address of the request, used for the download links of checkout and product attributes /// public DomainHost Host { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs index 9b7cf35fd3..78bc643c90 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs @@ -15,6 +15,11 @@ public class GetWishlist : IRequest public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } + + /// + /// Store address of the request, used for the download links of product attributes + /// + public DomainHost Host { get; set; } public TaxDisplayType TaxDisplayType { get; set; } public bool IsEditable { get; set; } = true; public IList Cart { get; set; } From 3211756f85409274c88991527c76d0ea7d730900 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 12:28:57 +0200 Subject: [PATCH 2/2] Address review findings on the explicit attribute formatter context - Shopping cart tokens: reset the attribute description and picture for each item and skip items whose product was deleted. - Customer out-of-stock subscriptions: fix the inverted attributes check. - PlaceOrder: render order item attribute descriptions in the working language again (PlaceOrderContainer.WorkingLanguage). - Shared wishlist: price attribute adjustments, unit price and subtotal for the viewing customer (GetWishlist.CurrentCustomer), as before. - Admin add product to order: format the stored description for the order's store, currency, language, tax display and primary host. - Admin cart and wishlist grids: price attribute adjustments for the cart item's store. - CampaignService: take currency, tax display and host from the caller instead of the ambient context. - GetMiniWishlist carries Host and TaxDisplayType; its handler no longer reads the ambient context. - ProductAttributeFormatter: require a context when rendering prices and render no download link without a host. - Cart requests share ICartCalculationRequest and one CalculationContext() extension instead of three copies. --- .../Products/ProductAttributeFormatter.cs | 11 ++++-- .../Orders/PlaceOrderCommandHandler.cs | 5 ++- .../Products/IProductAttributeFormatter.cs | 6 +-- .../Marketing/Campaigns/ICampaignService.cs | 15 +++++++- .../Utilities/Checkout/PlaceOrderContainer.cs | 5 +++ .../Services/Campaigns/CampaignService.cs | 34 +++++++++-------- .../GetShoppingCartTokensCommandHandler.cs | 7 +++- .../ProductAttributeFormatterTests.cs | 27 +++++++++++++ .../Campaigns/CampaignServiceTests.cs | 10 ++--- ...etShoppingCartTokensCommandHandlerTests.cs | 38 +++++++++++++++++++ .../Controllers/CampaignController.cs | 12 ++++-- .../Controllers/ShoppingCartController.cs | 4 +- .../Services/CustomerViewModelService.cs | 4 +- .../Services/OrderViewModelService.cs | 17 +++++++-- .../Controllers/WishlistController.cs | 5 ++- .../CartCalculationRequestExtensions.cs | 15 ++++++++ .../ShoppingCart/GetAddToCartHandler.cs | 5 +-- .../GetMiniShoppingCartHandler.cs | 3 +- .../ShoppingCart/GetMiniWishlistHandler.cs | 14 +++---- .../ShoppingCart/GetShoppingCartHandler.cs | 11 ++---- .../ShoppingCart/GetWishlistHandler.cs | 29 +++++--------- .../Models/ShoppingCart/GetAddToCart.cs | 2 +- .../ShoppingCart/GetMiniShoppingCart.cs | 2 +- .../Models/ShoppingCart/GetMiniWishlist.cs | 9 ++++- .../Models/ShoppingCart/GetShoppingCart.cs | 2 +- .../Models/ShoppingCart/GetWishlist.cs | 10 ++++- .../ShoppingCart/ICartCalculationRequest.cs | 16 ++++++++ 27 files changed, 229 insertions(+), 89 deletions(-) create mode 100644 src/Web/Grand.Web/Extensions/CartCalculationRequestExtensions.cs create mode 100644 src/Web/Grand.Web/Features/Models/ShoppingCart/ICartCalculationRequest.cs diff --git a/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs b/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs index b77651d6e0..ec13d9c5d0 100644 --- a/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs +++ b/src/Business/Grand.Business.Catalog/Services/Products/ProductAttributeFormatter.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Extensions; +using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Prices; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Catalog.Tax; @@ -42,9 +42,9 @@ public ProductAttributeFormatter(IProductAttributeService productAttributeServic /// /// Product /// Attributes - /// Customer, store, currency and tax display the prices are rendered for (read only when renderPrices is true) + /// Customer, store, currency and tax display the prices are rendered for (required when renderPrices is true) /// Language of the attribute names - /// Store address used for file download links (read only when allowHyperlinks is true) + /// Store address used for file download links (read only when allowHyperlinks is true; without it the link is not rendered) /// Separator /// A value indicating whether to encode (HTML) values /// A value indicating whether to render prices @@ -64,6 +64,9 @@ public virtual async Task FormatAttributes(Product product, IList PrepareFormattedAttribute(Product product, { var attributeText = string.Empty; var attributeName = productAttribute.GetTranslation(a => a.Name, langId); - if (allowHyperlinks) + if (allowHyperlinks && host != null) { var downloadLink = $"{host.Url.TrimEnd('/')}/download/getfileupload/?downloadId={downloadGuid}"; diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs index 1d68f81fec..f8e8280201 100644 --- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs +++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs @@ -387,6 +387,7 @@ protected virtual async Task PreparePlaceOrderDetails(Place Context = command.Context, Customer = command.Context.Customer, Store = command.Context.Store, + WorkingLanguage = command.Language, Host = command.Host }; if (details.Customer == null) @@ -443,7 +444,7 @@ protected virtual async Task PreparePlaceOrderDetails(Place details.Store.Id); details.CheckoutAttributeDescription = await _checkoutAttributeFormatter.FormatAttributes(details.CheckoutAttributes, details.Context, - command.Language, command.Host); + details.WorkingLanguage, details.Host); //load and validate customer shopping cart details.Cart = details.Customer.ShoppingCartItems @@ -653,7 +654,7 @@ protected virtual async Task PrepareOrderItem(ShoppingCartItem sc, Pr details.AppliedDiscounts.Add(disc); var attributeDescription = await _productAttributeFormatter.FormatAttributes(product, sc.Attributes, details.Context, - details.Language, details.Host); + details.WorkingLanguage, details.Host); var itemWeight = await GetShoppingCartItemWeight(sc); var warehouseId = !string.IsNullOrEmpty(sc.WarehouseId) ? sc.WarehouseId diff --git a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs index dedf8e79f3..4fc6842b84 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Catalog/Products/IProductAttributeFormatter.cs @@ -1,4 +1,4 @@ -using Grand.Business.Core.Utilities.Checkout; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Localization; @@ -16,9 +16,9 @@ public interface IProductAttributeFormatter /// /// Product /// Attributes - /// Customer, store, currency and tax display the prices are rendered for (read only when renderPrices is true) + /// Customer, store, currency and tax display the prices are rendered for (required when renderPrices is true) /// Language of the attribute names - /// Store address used for file download links (read only when allowHyperlinks is true) + /// Store address used for file download links (read only when allowHyperlinks is true; without it the link is not rendered) /// Separator /// A value indicating whether to encode (HTML) values /// A value indicating whether to render prices diff --git a/src/Business/Grand.Business.Core/Interfaces/Marketing/Campaigns/ICampaignService.cs b/src/Business/Grand.Business.Core/Interfaces/Marketing/Campaigns/ICampaignService.cs index c9ff1a9c9d..2cb3d104b8 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Marketing/Campaigns/ICampaignService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Marketing/Campaigns/ICampaignService.cs @@ -1,5 +1,8 @@ using Grand.Domain; +using Grand.Domain.Directory; using Grand.Domain.Messages; +using Grand.Domain.Stores; +using Grand.Domain.Tax; namespace Grand.Business.Core.Interfaces.Marketing.Campaigns; @@ -65,9 +68,13 @@ Task> CustomerSubscriptions(Campaign campaign /// Campaign /// Email account /// Subscriptions + /// Currency the shopping cart prices are rendered in + /// Whether the shopping cart prices include tax + /// Store address used for the links in the email /// Total emails sent Task SendCampaign(Campaign campaign, EmailAccount emailAccount, - IEnumerable subscriptions); + IEnumerable subscriptions, Currency currency, TaxDisplayType taxDisplayType, + DomainHost host); /// /// Sends a campaign to specified email @@ -75,5 +82,9 @@ Task SendCampaign(Campaign campaign, EmailAccount emailAccount, /// Campaign /// Email account /// Email - Task SendCampaign(Campaign campaign, EmailAccount emailAccount, string email); + /// Currency the shopping cart prices are rendered in + /// Whether the shopping cart prices include tax + /// Store address used for the links in the email + Task SendCampaign(Campaign campaign, EmailAccount emailAccount, string email, Currency currency, + TaxDisplayType taxDisplayType, DomainHost host); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs index e60774f26a..11d7624ce8 100644 --- a/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs +++ b/src/Business/Grand.Business.Core/Utilities/Checkout/PlaceOrderContainer.cs @@ -22,6 +22,11 @@ public class PlaceOrderContainer public Store Store { get; set; } public Language Language { get; set; } + /// + /// Language of the request placing the order, used for the attribute descriptions + /// + public Language WorkingLanguage { get; set; } + /// /// Store address used for file download links in the attribute descriptions /// diff --git a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs index 40a96f7c67..cbaf6c2efc 100644 --- a/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs +++ b/src/Business/Grand.Business.Marketing/Services/Campaigns/CampaignService.cs @@ -8,9 +8,10 @@ using Grand.Domain; using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Domain.Directory; using Grand.Domain.Messages; using Grand.Domain.Stores; -using Grand.Infrastructure; +using Grand.Domain.Tax; using Grand.Infrastructure.Extensions; using Grand.Mediator; @@ -25,8 +26,7 @@ public class CampaignService( IQueuedEmailService queuedEmailService, IStoreService storeService, IMediator mediator, - ILanguageService languageService, - IContextAccessor contextAccessor) + ILanguageService languageService) : ICampaignService { /// @@ -226,9 +226,13 @@ from customers in joined /// Campaign /// Email account /// Subscriptions + /// Currency the shopping cart prices are rendered in + /// Whether the shopping cart prices include tax + /// Store address used for the links in the email /// Total emails sent public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAccount, - IEnumerable subscriptions) + IEnumerable subscriptions, Currency currency, TaxDisplayType taxDisplayType, + DomainHost host) { ArgumentNullException.ThrowIfNull(campaign); ArgumentNullException.ThrowIfNull(emailAccount); @@ -254,12 +258,12 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emai (await storeService.GetAllStores()).FirstOrDefault(); builder.AddStoreTokens(store, language, emailAccount) - .AddNewsLetterSubscriptionTokens(subscription, store, contextAccessor.StoreContext.CurrentHost); + .AddNewsLetterSubscriptionTokens(subscription, store, host); if (customer != null) - builder.AddCustomerTokens(customer, store, contextAccessor.StoreContext.CurrentHost, language) - .AddShoppingCartTokens(ShoppingCartContext(customer, store), - contextAccessor.StoreContext.CurrentHost, language); + builder.AddCustomerTokens(customer, store, host, language) + .AddShoppingCartTokens(new CalculationContext(customer, store, currency, taxDisplayType), + host, language); var email = new QueuedEmail(); @@ -291,17 +295,17 @@ await InsertCampaignHistory(new CampaignHistory { return totalEmailsSent; } - // TODO M1 PR 5: the currency and tax display still come from the request that sends the campaign - private CalculationContext ShoppingCartContext(Customer customer, Store store) => - new(customer, store, contextAccessor.WorkContext.WorkingCurrency, contextAccessor.WorkContext.TaxDisplayType); - /// /// Sends a campaign to specified email /// /// Campaign /// Email account /// Email - public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAccount, string email) + /// Currency the shopping cart prices are rendered in + /// Whether the shopping cart prices include tax + /// Store address used for the links in the email + public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAccount, string email, + Currency currency, TaxDisplayType taxDisplayType, DomainHost host) { ArgumentNullException.ThrowIfNull(campaign); ArgumentNullException.ThrowIfNull(emailAccount); @@ -316,8 +320,8 @@ public virtual async Task SendCampaign(Campaign campaign, EmailAccount emailAcco builder.AddStoreTokens(store, language, emailAccount); var customer = customerRepository.Table.FirstOrDefault(x => x.Email == email.ToLowerInvariant()); if (customer != null) - builder.AddCustomerTokens(customer, store, contextAccessor.StoreContext.CurrentHost, language) - .AddShoppingCartTokens(ShoppingCartContext(customer, store), contextAccessor.StoreContext.CurrentHost, + builder.AddCustomerTokens(customer, store, host, language) + .AddShoppingCartTokens(new CalculationContext(customer, store, currency, taxDisplayType), host, language); var liquidObject = await builder.BuildAsync(); diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs index 485c21986b..ae91564b77 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Messages/GetShoppingCartTokensCommandHandler.cs @@ -30,11 +30,14 @@ public async Task Handle(GetShoppingCartTokensCommand reques async Task ProductList() { - var attributeDescription = string.Empty; - var pictureUrl = string.Empty; foreach (var item in request.Context.Customer.ShoppingCartItems) { var product = await _productService.GetProductById(item.ProductId); + if (product == null) + continue; + + var attributeDescription = string.Empty; + var pictureUrl = string.Empty; if (product.ProductPictures.Any()) pictureUrl = await _pictureService.GetPictureUrl( diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs index 73dda0e61a..75864aa4c3 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Products/ProductAttributeFormatterTests.cs @@ -166,4 +166,31 @@ public async Task FormatAttributes_FileUpload_LinksToTheGivenHost() $"test: file", result); } + + [TestMethod] + public async Task FormatAttributes_FileUpload_WithoutHost_RendersNoLink() + { + //Arrange + var product = new Product(); + product.ProductAttributeMappings.Add(new ProductAttributeMapping + { Id = "1", AttributeControlTypeId = AttributeControlType.FileUpload, TextPrompt = "file" }); + //Act + var result = await _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = Guid.NewGuid().ToString() } + }, _context, _language, null); + //Assert + Assert.AreEqual("test: ", result); + } + + [TestMethod] + public async Task FormatAttributes_WithPrices_RequiresAContext() + { + //Arrange + var product = ProductWithCheckboxValue(5); + //Act & Assert + await Assert.ThrowsExactlyAsync(() => + _productAttributeFormatter.FormatAttributes(product, new List { + new() { Key = "1", Value = "1" } + }, null, _language, _host)); + } } diff --git a/src/Tests/Grand.Business.Marketing.Tests/Services/Campaigns/CampaignServiceTests.cs b/src/Tests/Grand.Business.Marketing.Tests/Services/Campaigns/CampaignServiceTests.cs index 99a643c39f..ca32ce49fa 100644 --- a/src/Tests/Grand.Business.Marketing.Tests/Services/Campaigns/CampaignServiceTests.cs +++ b/src/Tests/Grand.Business.Marketing.Tests/Services/Campaigns/CampaignServiceTests.cs @@ -5,10 +5,11 @@ using Grand.Data; using Grand.Data.Tests.MongoDb; using Grand.Domain.Customers; +using Grand.Domain.Directory; using Grand.Domain.Localization; using Grand.Domain.Messages; using Grand.Domain.Stores; -using Grand.Infrastructure; +using Grand.Domain.Tax; using Grand.Infrastructure.Events; using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -29,7 +30,6 @@ public class CampaignServiceTests private IRepository _newsLetterSubscriptionRepository; private Mock _queuedEmailServiceMock; private Mock _storeServiceMock; - private Mock _workContextMock; [TestInitialize] public void Init() @@ -43,11 +43,10 @@ public void Init() _queuedEmailServiceMock = new Mock(); _storeServiceMock = new Mock(); _languageServiceMock = new Mock(); - _workContextMock = new Mock(); _campaignService = new CampaignService(_campaignRepository, _campaignHistoryRepository, _newsLetterSubscriptionRepository, _customerRepository, _emailSenderMock.Object, _queuedEmailServiceMock.Object, _storeServiceMock.Object, _mediatorMock.Object, - _languageServiceMock.Object, _workContextMock.Object); + _languageServiceMock.Object); } [TestMethod] @@ -184,7 +183,8 @@ await _newsLetterSubscriptionRepository.InsertAsync(new NewsLetterSubscription .Returns(Task.FromResult(new Store())); //Act - await _campaignService.SendCampaign(campaign, new EmailAccount { Host = "host.com" }, "test@test.com"); + await _campaignService.SendCampaign(campaign, new EmailAccount { Host = "host.com" }, "test@test.com", + new Currency(), TaxDisplayType.IncludingTax, new DomainHost { Url = "https://localhost/" }); //Assert _emailSenderMock.Verify( diff --git a/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs b/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs index 0d2c2b513f..3487e3b1a4 100644 --- a/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs +++ b/src/Tests/Grand.Business.Messages.Tests/Commands/GetShoppingCartTokensCommandHandlerTests.cs @@ -48,4 +48,42 @@ public async Task Handle_FormatsAttributesForTheMessageContextLanguageAndHost() command.Host, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + + [TestMethod] + public async Task Handle_ItemWithoutAttributes_DoesNotRepeatThePreviousItemsDescription_AndSkipsDeletedProducts() + { + //Arrange + var product = new Product { Id = "p1" }; + var productService = new Mock(); + productService.Setup(x => x.GetProductById("p1", false)).ReturnsAsync(product); + var formatter = new Mock(); + formatter.Setup(x => x.FormatAttributes(product, It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync("Size: XL"); + var customer = new Customer(); + var withAttributes = new ShoppingCartItem { ProductId = "p1" }; + withAttributes.Attributes.Add(new CustomAttribute { Key = "1", Value = "1" }); + customer.ShoppingCartItems.Add(withAttributes); + customer.ShoppingCartItems.Add(new ShoppingCartItem { ProductId = "deleted" }); + customer.ShoppingCartItems.Add(new ShoppingCartItem { ProductId = "p1" }); + var command = new GetShoppingCartTokensCommand { + Context = new CalculationContext(customer, new Store { Id = "s1" }, new Currency(), + TaxDisplayType.ExcludingTax), + Language = new Language { Id = "pl" }, + Host = new DomainHost { Url = "https://shop.example/" } + }; + var handler = new GetShoppingCartTokensCommandHandler(new Mock().Object, + productService.Object, formatter.Object); + + //Act + var result = await handler.Handle(command, CancellationToken.None); + + //Assert + var items = result.Items.ToList(); + Assert.HasCount(2, items); + Assert.AreEqual("Size: XL", items[0].AttributeDescription); + Assert.AreEqual(string.Empty, items[1].AttributeDescription); + } } diff --git a/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs b/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs index 9ccc2cc185..506dcce1e3 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CampaignController.cs @@ -213,12 +213,16 @@ await _newsLetterSubscriptionService.GetNewsLetterSubscriptionByEmailAndStoreId( var subscriptions = new List { subscription }; - await _campaignService.SendCampaign(campaign, emailAccount, subscriptions); + await _campaignService.SendCampaign(campaign, emailAccount, subscriptions, + _contextAccessor.WorkContext.WorkingCurrency, _contextAccessor.WorkContext.TaxDisplayType, + _contextAccessor.StoreContext.CurrentHost); } else { //no subscription found - await _campaignService.SendCampaign(campaign, emailAccount, model.TestEmail); + await _campaignService.SendCampaign(campaign, emailAccount, model.TestEmail, + _contextAccessor.WorkContext.WorkingCurrency, _contextAccessor.WorkContext.TaxDisplayType, + _contextAccessor.StoreContext.CurrentHost); } Success(_translationService.GetResource("admin.marketing.Campaigns.TestEmailSentToCustomers")); @@ -254,7 +258,9 @@ public async Task SendMassEmail(CampaignModel model) //subscribers of certain store? var store = await _storeService.GetStoreById(campaign.StoreId); var subscriptions = await _campaignService.CustomerSubscriptions(campaign); - var totalEmailsSent = await _campaignService.SendCampaign(campaign, emailAccount, subscriptions); + var totalEmailsSent = await _campaignService.SendCampaign(campaign, emailAccount, subscriptions, + _contextAccessor.WorkContext.WorkingCurrency, _contextAccessor.WorkContext.TaxDisplayType, + _contextAccessor.StoreContext.CurrentHost); Success( string.Format(_translationService.GetResource("admin.marketing.Campaigns.MassEmailSentToCustomers"), totalEmailsSent)); diff --git a/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs index 85b173acd9..c5bb98aa72 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ShoppingCartController.cs @@ -114,7 +114,7 @@ public async Task GetCartDetails(string customerId) Quantity = sci.Quantity, ProductName = product.Name, AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, - _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.CalculationContext() with { Customer = customer, Store = priceStore }, _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = _priceFormatter.FormatPrice((await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, @@ -188,7 +188,7 @@ public async Task GetWishlistDetails(string customerId) Quantity = sci.Quantity, ProductName = product.Name, AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, - _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.CalculationContext() with { Customer = customer, Store = priceStore }, _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = _priceFormatter.FormatPrice((await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index 7238c7b697..0d1319c084 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -875,7 +875,7 @@ public virtual async Task> PrepareShoppingCartItemM Quantity = sci.Quantity, ProductName = product.Name, AttributeInfo = await productAttributeFormatter.FormatAttributes(product, sci.Attributes, - _contextAccessor.CalculationContext() with { Customer = customer }, + _contextAccessor.CalculationContext() with { Customer = customer, Store = priceStore }, _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost), UnitPrice = priceFormatter.FormatPrice(price, _contextAccessor.WorkContext.WorkingCurrency), UnitPriceValue = price, @@ -1107,7 +1107,7 @@ await outOfStockSubscriptionService.GetAllSubscriptionsByCustomerId(customerId, StoreName = store != null ? store.Shortcut : "Unknown", ProductId = x.ProductId, ProductName = product != null ? product.Name : "Unknown", - AttributeDescription = x.Attributes != null || !x.Attributes!.Any() + AttributeDescription = x.Attributes == null || !x.Attributes.Any() ? "" : await productAttributeFormatter.FormatAttributes(product, x.Attributes, _contextAccessor.CalculationContext(), _contextAccessor.WorkContext.WorkingLanguage, diff --git a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs index 6ca2c68334..ab9b76fbbf 100644 --- a/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/OrderViewModelService.cs @@ -16,6 +16,7 @@ using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Interfaces.Storage; using Grand.Business.Core.Queries.Catalog; +using Grand.Business.Core.Utilities.Checkout; using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Domain.Directory; @@ -80,6 +81,7 @@ public class OrderViewModelService : IOrderViewModelService private readonly IMediator _mediator; private readonly IEnumTranslationService _enumTranslationService; private readonly IAdminDataScope _scope; + private readonly ILanguageService _languageService; #endregion @@ -120,7 +122,8 @@ public OrderViewModelService(IOrderService orderService, IOrderStatusService orderStatusService, IMediator mediator, IProductAttributeFormatter productAttributeFormatter, IEnumTranslationService enumTranslationService, - IAdminDataScope scope) + IAdminDataScope scope, + ILanguageService languageService) { _orderService = orderService; _pricingService = priceCalculationService; @@ -159,6 +162,7 @@ public OrderViewModelService(IOrderService orderService, _productAttributeFormatter = productAttributeFormatter; _enumTranslationService = enumTranslationService; _scope = scope; + _languageService = languageService; } #endregion @@ -1160,10 +1164,17 @@ await _shoppingCartValidator.GetShoppingCartItemGiftVoucherWarnings(customer, pr shoppingCartItem)); if (warnings.Count == 0) { + //the description is stored on the order, so it is rendered for the order, not for the admin + var store = await _storeService.GetStoreById(order.StoreId) ?? _contextAccessor.StoreContext.CurrentStore; + var currency = await _currencyService.GetCurrencyByCode(order.CustomerCurrencyCode) ?? + _contextAccessor.WorkContext.WorkingCurrency; + var language = await _languageService.GetLanguageById(order.CustomerLanguageId) ?? + _contextAccessor.WorkContext.WorkingLanguage; + var host = store.Domains.FirstOrDefault(x => x.Primary) ?? store.Domains.FirstOrDefault(); var attributeDescription = await _productAttributeFormatter.FormatAttributes(product, customattributes, - _contextAccessor.CalculationContext() with { Customer = customer }, - _contextAccessor.WorkContext.WorkingLanguage, _contextAccessor.StoreContext.CurrentHost); + new CalculationContext(customer, store, currency, order.CustomerTaxDisplayTypeId), + language, host); //save item var orderItem = new OrderItem { diff --git a/src/Web/Grand.Web/Controllers/WishlistController.cs b/src/Web/Grand.Web/Controllers/WishlistController.cs index 7e9a94f999..458f171a6f 100644 --- a/src/Web/Grand.Web/Controllers/WishlistController.cs +++ b/src/Web/Grand.Web/Controllers/WishlistController.cs @@ -72,7 +72,9 @@ public async Task> SidebarWishlist() Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Host = _contextAccessor.StoreContext.CurrentHost }); return Json(model); @@ -96,6 +98,7 @@ public virtual async Task> Index(Guid? customerGuid) var model = await _mediator.Send(new GetWishlist { Cart = cart, Customer = customer, + CurrentCustomer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, Store = _contextAccessor.StoreContext.CurrentStore, diff --git a/src/Web/Grand.Web/Extensions/CartCalculationRequestExtensions.cs b/src/Web/Grand.Web/Extensions/CartCalculationRequestExtensions.cs new file mode 100644 index 0000000000..55a577ef72 --- /dev/null +++ b/src/Web/Grand.Web/Extensions/CartCalculationRequestExtensions.cs @@ -0,0 +1,15 @@ +using Grand.Business.Core.Utilities.Checkout; +using Grand.Web.Features.Models.ShoppingCart; + +namespace Grand.Web.Extensions; + +public static class CartCalculationRequestExtensions +{ + /// + /// Gets the calculation context of a cart request: its customer, store, currency and tax display + /// + public static CalculationContext CalculationContext(this ICartCalculationRequest request) + { + return new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType); + } +} diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs index ed6866dd6e..5588c93d1a 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetAddToCartHandler.cs @@ -69,7 +69,7 @@ public async Task Handle(GetAddToCart request, CancellationToken var model = new AddToCartModel { AttributeDescription = await _productAttributeFormatter.FormatAttributes(request.Product, request.Attributes, - new CalculationContext(request.Customer, request.Store, request.Currency, request.TaxDisplayType), + request.CalculationContext(), request.Language, request.Host), ProductSeName = request.Product.GetSeName(request.Language.Id), CartType = request.CartType, @@ -160,8 +160,7 @@ await _productAttributeFormatter.FormatAttributes(request.Product, request.Attri !_taxSettings.ForceTaxExclusionFromOrderSubtotal; var shoppingCartSubTotal = await _orderTotalCalculationService.GetShoppingCartSubTotal(cart, - new CalculationContext(request.Customer, request.Store, request.Currency, - request.TaxDisplayType), subTotalIncludingTax); + request.CalculationContext(), subTotalIncludingTax); model.SubTotalIncludingTax = subTotalIncludingTax; model.SubTotal = _priceFormatter.FormatPrice(shoppingCartSubTotal.subTotalWithoutDiscount, request.Currency); diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs index dbc62cffa8..89f6d407b5 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniShoppingCartHandler.cs @@ -108,8 +108,7 @@ public async Task Handle(GetMiniShoppingCart request, model.TotalProducts = cart.Sum(x => x.Quantity); if (!cart.Any()) return model; - var context = new CalculationContext(request.Customer, request.Store, request.Currency, - request.TaxDisplayType); + var context = request.CalculationContext(); //subtotal var subTotalIncludingTax = request.TaxDisplayType == TaxDisplayType.IncludingTax && diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs index 22dc7d1bb5..3ef17125b7 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetMiniWishlistHandler.cs @@ -21,7 +21,6 @@ namespace Grand.Web.Features.Handlers.ShoppingCart; public class GetMiniWishlistHandler : IRequestHandler { - private readonly IContextAccessor _contextAccessor; private readonly LinkGenerator _linkGenerator; private readonly MediaSettings _mediaSettings; private readonly IPictureService _pictureService; @@ -43,10 +42,8 @@ public GetMiniWishlistHandler( IPictureService pictureService, LinkGenerator linkGenerator, ShoppingCartSettings shoppingCartSettings, - MediaSettings mediaSettings, - IContextAccessor contextAccessor) + MediaSettings mediaSettings) { - _contextAccessor = contextAccessor; _productService = productService; _productAttributeFormatter = productAttributeFormatter; _translationService = translationService; @@ -72,6 +69,7 @@ public async Task Handle(GetMiniWishlist request, Cancellatio #region Cart items + var context = request.CalculationContext(); foreach (var sci in request.Cart) { var product = await _productService.GetProductById(sci.ProductId); @@ -88,7 +86,7 @@ public async Task Handle(GetMiniWishlist request, Cancellatio ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, - _contextAccessor.CalculationContext(), request.Language, _contextAccessor.StoreContext.CurrentHost) + context, request.Language, request.Host) }; //unit prices @@ -100,10 +98,8 @@ public async Task Handle(GetMiniWishlist request, Cancellatio { var productprice = await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, - _contextAccessor.WorkContext.WorkingCurrency)).unitprice, - _contextAccessor.WorkContext.TaxDisplayType == TaxDisplayType.IncludingTax, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore); + context.Customer, context.Store, context.Currency)).unitprice, + context.IncludingTax, context.Customer, context.Store); cartItemModel.UnitPrice = _priceFormatter.FormatPrice(productprice.productprice, request.Currency); cartItemModel.UnitPriceValue = productprice.productprice; diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 2b37c7898c..3d549b7822 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -146,9 +146,6 @@ public async Task Handle(GetShoppingCart request, Cancellatio return model; } - private static CalculationContext CalculationContextOf(GetShoppingCart request) => - new(request.Customer, request.Store, request.Currency, request.TaxDisplayType); - private async Task PrepareSimpleProperties(ShoppingCartModel model, GetShoppingCart request) { #region Simple properties @@ -164,7 +161,7 @@ private async Task PrepareSimpleProperties(ShoppingCartModel model, GetShoppingC request.Customer.GetUserFieldFromEntity>(SystemCustomerFieldNames.CheckoutAttributes, request.Store.Id); model.CheckoutAttributeInfo = - await _checkoutAttributeFormatter.FormatAttributes(checkoutAttributes, CalculationContextOf(request), + await _checkoutAttributeFormatter.FormatAttributes(checkoutAttributes, request.CalculationContext(), request.Language, request.Host); if (!request.Cart.Where(x => x.ShoppingCartTypeId is ShoppingCartType.ShoppingCart or ShoppingCartType.Auctions) .ToList().Any()) @@ -174,7 +171,7 @@ await _checkoutAttributeFormatter.FormatAttributes(checkoutAttributes, Calculati else { var minOrderSubtotalAmountOk = await _mediator.Send(new ValidateMinShoppingCartSubtotalAmountCommand { - Context = CalculationContextOf(request), + Context = request.CalculationContext(), Cart = request.Cart.Where(x => x.ShoppingCartTypeId is ShoppingCartType.ShoppingCart or ShoppingCartType.Auctions).ToList() }); @@ -209,7 +206,7 @@ await _currencyService.ConvertFromPrimaryStoreCurrency(_orderSettings.MinOrderSu //cart warnings var cartWarnings = await _shoppingCartValidator.GetShoppingCartWarnings(request.Cart, checkoutAttributes, - CalculationContextOf(request), + request.CalculationContext(), request.ValidateCheckoutAttributes, true); foreach (var warning in cartWarnings) model.Warnings.Add(warning); @@ -365,7 +362,7 @@ private async Task PrepareCartItems(ShoppingCartModel model, GetShoppingCart req ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, - CalculationContextOf(request), request.Language, request.Host), + request.CalculationContext(), request.Language, request.Host), AllowItemEditing = _shoppingCartSettings.AllowCartItemEditing && product.VisibleIndividually }; diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs index bfcda1ccd3..139b2d6859 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetWishlistHandler.cs @@ -28,7 +28,6 @@ namespace Grand.Web.Features.Handlers.ShoppingCart; public class GetWishlistHandler : IRequestHandler { - private readonly IContextAccessor _contextAccessor; private readonly CatalogSettings _catalogSettings; private readonly LinkGenerator _linkGenerator; private readonly MediaSettings _mediaSettings; @@ -57,10 +56,8 @@ public GetWishlistHandler( ShoppingCartSettings shoppingCartSettings, CatalogSettings catalogSettings, MediaSettings mediaSettings, - IEnumTranslationService enumTranslationService, - IContextAccessor contextAccessor) + IEnumTranslationService enumTranslationService) { - _contextAccessor = contextAccessor; _permissionService = permissionService; _productService = productService; _productAttributeFormatter = productAttributeFormatter; @@ -77,9 +74,6 @@ public GetWishlistHandler( _enumTranslationService = enumTranslationService; } - private static CalculationContext CalculationContextOf(GetWishlist request) => - new(request.Customer, request.Store, request.Currency, request.TaxDisplayType); - public async Task Handle(GetWishlist request, CancellationToken cancellationToken) { var model = new WishlistModel { @@ -100,7 +94,7 @@ public async Task Handle(GetWishlist request, CancellationToken c //cart warnings var cartWarnings = await _shoppingCartValidator.GetShoppingCartWarnings(request.Cart, null, - CalculationContextOf(request), false, false); + request.CalculationContext(), false, false); foreach (var warning in cartWarnings) model.Warnings.Add(warning); @@ -108,6 +102,8 @@ public async Task Handle(GetWishlist request, CancellationToken c #region Cart items + //on a shared wishlist the prices are the viewer's, not the owner's + var priceContext = request.CalculationContext() with { Customer = request.CurrentCustomer }; foreach (var sci in request.Cart) { var product = await _productService.GetProductById(sci.ProductId); @@ -124,7 +120,7 @@ public async Task Handle(GetWishlist request, CancellationToken c ProductUrl = _linkGenerator.GetPathByRouteValues("Product", new { SeName = sename }), Quantity = sci.Quantity, AttributeInfo = await _productAttributeFormatter.FormatAttributes(product, sci.Attributes, - CalculationContextOf(request), request.Language, request.Host), + priceContext, request.Language, request.Host), AllowItemEditing = _shoppingCartSettings.AllowCartItemEditing && product.VisibleIndividually }; @@ -154,10 +150,8 @@ public async Task Handle(GetWishlist request, CancellationToken c { var productprice = await _taxService.GetProductPrice(product, (await _pricingService.GetUnitPrice(sci, product, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, - _contextAccessor.WorkContext.WorkingCurrency)).unitprice, - _contextAccessor.WorkContext.TaxDisplayType == TaxDisplayType.IncludingTax, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore); + priceContext.Customer, priceContext.Store, priceContext.Currency)).unitprice, + priceContext.IncludingTax, priceContext.Customer, priceContext.Store); cartItemModel.UnitPrice = _priceFormatter.FormatPrice(productprice.productprice, request.Currency); } @@ -170,12 +164,10 @@ public async Task Handle(GetWishlist request, CancellationToken c { //sub total var subtotal = await _pricingService.GetSubTotal(sci, product, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, - _contextAccessor.WorkContext.WorkingCurrency); + priceContext.Customer, priceContext.Store, priceContext.Currency); var shoppingCartItemDiscountBase = subtotal.discountAmount; var productprices = await _taxService.GetProductPrice(product, subtotal.subTotal, - _contextAccessor.WorkContext.TaxDisplayType == TaxDisplayType.IncludingTax, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore); + priceContext.IncludingTax, priceContext.Customer, priceContext.Store); cartItemModel.SubTotal = _priceFormatter.FormatPrice(productprices.productprice, request.Currency); //display an applied discount amount @@ -183,8 +175,7 @@ public async Task Handle(GetWishlist request, CancellationToken c { shoppingCartItemDiscountBase = (await _taxService.GetProductPrice(product, shoppingCartItemDiscountBase, - _contextAccessor.WorkContext.TaxDisplayType == TaxDisplayType.IncludingTax, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore)).productprice; + priceContext.IncludingTax, priceContext.Customer, priceContext.Store)).productprice; if (shoppingCartItemDiscountBase > 0) cartItemModel.Discount = _priceFormatter.FormatPrice(shoppingCartItemDiscountBase, request.Currency); diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs index 905283d95c..70401cec89 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetAddToCart.cs @@ -11,7 +11,7 @@ namespace Grand.Web.Features.Models.ShoppingCart; -public class GetAddToCart : IRequest +public class GetAddToCart : IRequest, ICartCalculationRequest { public Product Product { get; set; } public Customer Customer { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs index ca3b8190a3..8f2a18e432 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniShoppingCart.cs @@ -8,7 +8,7 @@ namespace Grand.Web.Features.Models.ShoppingCart; -public class GetMiniShoppingCart : IRequest +public class GetMiniShoppingCart : IRequest, ICartCalculationRequest { public Customer Customer { get; set; } public Language Language { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniWishlist.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniWishlist.cs index a90480f3e8..5affcbc6e0 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniWishlist.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetMiniWishlist.cs @@ -3,16 +3,23 @@ using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Domain.Stores; +using Grand.Domain.Tax; using Grand.Web.Models.ShoppingCart; using Grand.Mediator; namespace Grand.Web.Features.Models.ShoppingCart; -public class GetMiniWishlist : IRequest +public class GetMiniWishlist : IRequest, ICartCalculationRequest { public Customer Customer { get; set; } public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } + + /// + /// Store address of the request, used for the download links of product attributes + /// + public DomainHost Host { get; set; } + public TaxDisplayType TaxDisplayType { get; set; } public IList Cart { get; set; } } \ No newline at end of file diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs index d225058eb7..bb2e65e6a0 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs @@ -9,7 +9,7 @@ namespace Grand.Web.Features.Models.ShoppingCart; -public class GetShoppingCart : IRequest +public class GetShoppingCart : IRequest, ICartCalculationRequest { public Customer Customer { get; set; } public Language Language { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs index 78bc643c90..5317cb0f73 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetWishlist.cs @@ -9,9 +9,17 @@ namespace Grand.Web.Features.Models.ShoppingCart; -public class GetWishlist : IRequest +public class GetWishlist : IRequest, ICartCalculationRequest { + /// + /// Owner of the wishlist + /// public Customer Customer { get; set; } + + /// + /// Customer viewing the wishlist; prices are calculated for them, which differs from Customer on a shared wishlist + /// + public Customer CurrentCustomer { get; set; } public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/ICartCalculationRequest.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/ICartCalculationRequest.cs new file mode 100644 index 0000000000..f611da0252 --- /dev/null +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/ICartCalculationRequest.cs @@ -0,0 +1,16 @@ +using Grand.Domain.Customers; +using Grand.Domain.Directory; +using Grand.Domain.Tax; + +namespace Grand.Web.Features.Models.ShoppingCart; + +/// +/// Cart request that carries the values a calculation context is built from +/// +public interface ICartCalculationRequest +{ + Customer Customer { get; } + Domain.Stores.Store Store { get; } + Currency Currency { get; } + TaxDisplayType TaxDisplayType { get; } +}