diff --git a/.gitignore b/.gitignore index 6822fbf4f..faea5c658 100644 --- a/.gitignore +++ b/.gitignore @@ -380,4 +380,5 @@ src/Web/Grand.Web.Store/App_Data/Settings.cfg src/Web/Grand.Web.Store/Plugins/* src/Web/Grand.Web.Store/Modules/* src/Web/Grand.Web.Store/App_Data/DataProtectionKeys/* -src/Web/Grand.Web.Store/wwwroot/assets/images/thumbs/*.** \ No newline at end of file +src/Web/Grand.Web.Store/wwwroot/assets/images/thumbs/*.**.worktrees/ +docs/superpowers/ diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt deleted file mode 100644 index 8d4ae37e4..000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt +++ /dev/null @@ -1,8 +0,0 @@ -{ - Id: pac-v-1, - StockQuantity: 5, - AllowOutOfStockOrders: false, - Sku: COMB-SKU, - NotifyAdminForQuantityBelow: 1, - UseMultipleWarehouses: false -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt deleted file mode 100644 index 2e5ae0370..000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt +++ /dev/null @@ -1,12 +0,0 @@ -{ - ProductAttributeId: pa-1, - TextPrompt: Choose size, - IsRequired: true, - ShowOnCatalogPage: false, - AttributeControlTypeId: DropdownList, - Combination: false, - ShouldHaveValues: false, - ValidationRulesAllowed: false, - ConditionAllowed: false, - Id: pam-v-1 -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt deleted file mode 100644 index b7826ea54..000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt +++ /dev/null @@ -1,57 +0,0 @@ -{ - Id: prod-v-1, - ProductTypeId: 5, - AuctionEnded: false, - VisibleIndividually: false, - Name: Vendor Product, - ShortDescription: Short desc, - FullDescription: Full description, - AllowCustomerReviews: false, - Sku: VSKU001, - IsGiftVoucher: false, - RequireOtherProducts: false, - AutoAddRequiredProducts: false, - IsRecurring: false, - CalendarModel: { - Interval: 1, - IncBothDate: false, - Quantity: 1, - Monday: false, - Tuesday: false, - Wednesday: false, - Thursday: false, - Friday: false, - Saturday: false, - Sunday: false - }, - IsShipEnabled: false, - IsFreeShipping: false, - ShipSeparately: false, - IsTaxExempt: false, - IsTele: false, - UseMultipleWarehouses: false, - StockQuantity: 50, - StockAvailability: false, - DisplayStockQuantity: false, - AllowOutOfStockSubscriptions: false, - NotReturnable: false, - DisableBuyButton: false, - DisableWishlistButton: false, - AvailableForPreOrder: false, - CallForPrice: false, - Price: 49.99, - OldPrice: 59.99, - EnteredPrice: false, - BasepriceEnabled: false, - MarkAsNew: false, - Weight: 1.5, - DisplayOrder: 1, - Published: true, - AddPictureModel: { - IsDefault: false - }, - CopyProductModel: { - CopyImages: false, - Published: false - } -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt deleted file mode 100644 index 0cb803d5f..000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt +++ /dev/null @@ -1,44 +0,0 @@ -{ - VisibleIndividually: false, - Name: New Product, - ShortDescription: Short, - FullDescription: Full, - ShowOnHomePage: false, - BestSeller: false, - AllowCustomerReviews: false, - Sku: SKU002, - IsGiftVoucher: false, - RequireOtherProducts: false, - AutoAddRequiredProducts: false, - IsDownload: false, - UnlimitedDownloads: false, - HasSampleDownload: false, - HasUserAgreement: false, - IsRecurring: false, - IncBothDate: false, - IsShipEnabled: false, - IsFreeShipping: false, - ShipSeparately: false, - IsTaxExempt: false, - IsTele: false, - UseMultipleWarehouses: false, - StockQuantity: 25, - StockAvailability: false, - DisplayStockQuantity: false, - LowStock: false, - AllowOutOfStockSubscriptions: false, - NotReturnable: false, - DisableBuyButton: false, - DisableWishlistButton: false, - AvailableForPreOrder: false, - CallForPrice: false, - Price: 39.99, - EnteredPrice: false, - BasepriceEnabled: false, - MarkAsNew: false, - AuctionEnded: false, - Published: true, - LimitedToGroups: false, - LimitedToStores: false, - Id: ObjectId_1 -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs index da92f106f..c055c1dde 100644 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs +++ b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs @@ -1,18 +1,22 @@ using Grand.Mapping; -using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Web.Vendor.Mapper; -using Grand.Web.Vendor.Models.Catalog; using Grand.Web.Vendor.Models.Common; using Grand.Web.Vendor.Models.Vendor; using Microsoft.VisualStudio.TestTools.UnitTesting; using VerifyMSTest; using AddressProfile = Grand.Web.Vendor.Mapper.AddressProfile; -using ProductProfile = Grand.Web.Vendor.Mapper.ProductProfile; using VendorProfile = Grand.Web.Vendor.Mapper.VendorProfile; namespace Grand.Mapping.Tests.Vendor; +// Product-related cases (Product/ProductAttributeMapping/ProductAttributeCombination <-> Vendor's +// Models.Catalog) were removed here (ARCH-001 Phase 1 Task 13): the Vendor.Mapper.ProductProfile they +// exercised, and the Vendor.Models.Catalog types they mapped to/from, were deleted as orphans once +// Task 12 repointed Vendor's _ViewImports.cshtml to AdminShared's models/service. The equivalent +// coverage - mapping Product/ProductAttributeMapping/ProductAttributeCombination to/from AdminShared's +// ProductModel via AdminShared's ProductProfile, which Vendor now uses - lives in +// Grand.Mapping.Tests.AdminShared.CatalogProductMappingTests. [TestClass] public class VendorMappingTests : VerifyBase { @@ -23,7 +27,6 @@ public void Setup() { var config = new MapperConfiguration(cfg => { cfg.AddProfile(); - cfg.AddProfile(); cfg.AddProfile(); }); _mapper = config.CreateMapper(); @@ -88,73 +91,4 @@ public Task VendorModel_ToVendor() }; return Verify(_mapper.Map(model)); } - - // ── Product ─────────────────────────────────────────────────────────────── - - [TestMethod] - public Task Product_ToVendorProductModel() - { - var entity = new Product { - Id = "prod-v-1", - Name = "Vendor Product", - ShortDescription = "Short desc", - FullDescription = "Full description", - Sku = "VSKU001", - Price = 49.99, - OldPrice = 59.99, - Published = true, - StockQuantity = 50, - Weight = 1.5, - DisplayOrder = 1, - ProductTypeId = (ProductType)5 - }; - return Verify(_mapper.Map(entity)); - } - - [TestMethod] - public Task VendorProductModel_ToProduct() - { - var model = new ProductModel { - Name = "New Product", - ShortDescription = "Short", - FullDescription = "Full", - Sku = "SKU002", - Price = 39.99, - Published = true, - StockQuantity = 25 - }; - return Verify(_mapper.Map(model)); - } - - // ── ProductAttributeMapping ─────────────────────────────────────────────── - - [TestMethod] - public Task ProductAttributeMapping_ToVendorModel() - { - var entity = new ProductAttributeMapping { - Id = "pam-v-1", - ProductAttributeId = "pa-1", - TextPrompt = "Choose size", - IsRequired = true, - AttributeControlTypeId = (AttributeControlType)1, - DisplayOrder = 0 - }; - return Verify(_mapper.Map(entity)); - } - - // ── ProductAttributeCombination ─────────────────────────────────────────── - - [TestMethod] - public Task ProductAttributeCombination_ToVendorModel() - { - var entity = new ProductAttributeCombination { - Id = "pac-v-1", - Sku = "COMB-SKU", - StockQuantity = 5, - AllowOutOfStockOrders = false, - OverriddenPrice = null, - NotifyAdminForQuantityBelow = 1 - }; - return Verify(_mapper.Map(entity)); - } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs new file mode 100644 index 000000000..50c839a6d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -0,0 +1,5582 @@ +using Grand.Business.Core.Dto; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +// Characterization tests for the merged access-check behavior in BaseProductController's +// "Product list / create / edit / delete" region (ARCH-001 Phase 1 Task 7). These replace the +// equivalent per-host access-check cases in Grand.Web.Admin/Store/Vendor.Tests ProductControllerTests +// (removed in Task 13), parameterized over a mocked IAdminDataScope instead of three +// different concrete access mechanisms (Admin: none: Store: AccessToEntityByStore; Vendor: HasAccessToProduct). +[TestClass] +public class BaseProductControllerTests +{ + // BaseProductController is abstract - it has no host until Task 11 subclasses it. This + // minimal subclass exists only so the actions under test can be invoked directly. + private class TestProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, + languageService, translationService, productReservationService, auctionService, + dateTimeService, permissionService, enumTranslationService, scope); + + private TestProductController _controller; + private Mock _productServiceMock; + private Mock _productViewModelServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + private Mock _permissionServiceMock; + private Mock _productReservationServiceMock; + private Mock _auctionServiceMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + AutoMapperConfig.Init(mapperConfig); + + _productServiceMock = new Mock(); + _productViewModelServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + _permissionServiceMock = new Mock(); + _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _productReservationServiceMock = new Mock(); + _auctionServiceMock = new Mock(); + + _controller = new TestProductController( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + new Mock().Object, + languageServiceMock.Object, + _translationServiceMock.Object, + _productReservationServiceMock.Object, + _auctionServiceMock.Object, + new Mock().Object, + _permissionServiceMock.Object, + new Mock().Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + // Needed for actions whose catch (Exception) block calls Error(exc), which logs via + // HttpContext.RequestServices.GetRequiredService() (see Export / Import below). + // Once RequestServices is non-null, ControllerBase.Url's own + // HttpContext.RequestServices.GetRequiredService() call (used by every + // RedirectToAction(action, controller) - e.g. GoToSku above) stops being short-circuited by the + // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve + // too or those pre-existing tests start throwing. + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) + .Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + // --- Edit (GET) -------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditGet_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Edit("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.CanView(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditGet_ScopeDeniesView_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.Edit("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify( + s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task EditGet_ScopeGrantsView_ShowsForm() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.Edit("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductModel(It.IsAny(), product, false, false), Times.Once); + } + + [TestMethod] + public async Task EditGet_UsesCanViewNotHasAccess_LooserRuleWins() + { + // Regression guard for the review fix: Edit(GET) must gate on the looser CanView, not the + // strict mutation-only HasAccess. A product HasAccess would deny (e.g. Store's multi-store + // rule) but CanView allows must still show the form. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.Edit("p1"); + + Assert.IsInstanceOfType(result); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + // --- Edit (POST) ------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditPost_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Edit(new ProductModel { Id = "missing" }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify( + s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_ScopeDeniesAccess_RedirectsToEditWithoutUpdating() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + _productViewModelServiceMock.Verify( + s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1", Ticks = 5 }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.UpdateProductModel(product, It.IsAny())) + .ReturnsAsync(product); + + var result = await _controller.Edit(new ProductModel { Id = "p1", Ticks = 5 }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.UpdateProductModel(product, It.IsAny()), Times.Once); + } + + // --- Delete -------------------------------------------------------------------------------------- + + [TestMethod] + public async Task Delete_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Delete("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeDeniesAccess_RedirectsToEditWithoutDeleting() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.Delete("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeGrantsAccess_DeletesAndRedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.Delete("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); + } + + // --- CopyProduct ----------------------------------------------------------------------------------- + + [TestMethod] + public async Task CopyProduct_ScopeDeniesView_RedirectsToListWithoutCopying() + { + var originalProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(false); + var copyProductServiceMock = new Mock(); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy" } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + copyProductServiceMock.Verify( + s => s.CopyProduct(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CopyProduct_ScopeGrantsView_Copies() + { + var originalProduct = new Product { Id = "p1" }; + var newProduct = new Product { Id = "p2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(true); + var copyProductServiceMock = new Mock(); + copyProductServiceMock.Setup(s => s.CopyProduct(originalProduct, "copy", false)).ReturnsAsync(newProduct); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy", Published = false, CopyImages = false } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p2", redirect.RouteValues["id"]); + } + + [TestMethod] + public async Task CopyProduct_UsesCanViewNotHasAccess_LooserRuleWins() + { + // Regression guard: CopyProduct must gate on CanView (Store's original rule: denies only + // when LimitedToStores excludes the staff store), not the strict HasAccess. + var originalProduct = new Product { Id = "p1" }; + var newProduct = new Product { Id = "p2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(true); + var copyProductServiceMock = new Mock(); + copyProductServiceMock.Setup(s => s.CopyProduct(originalProduct, "copy", false)).ReturnsAsync(newProduct); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy", Published = false, CopyImages = false } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p2", redirect.RouteValues["id"]); + } + + // --- DeleteSelected ------------------------------------------------------------------------------ + + [TestMethod] + public async Task DeleteSelected_NoIds_DoesNotCallService() + { + var result = await _controller.DeleteSelected(new List()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + _productViewModelServiceMock.Verify(s => s.DeleteSelected(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task DeleteSelected_FiltersOutProductsScopeDenies() + { + // Regression guard for the review fix: DeleteSelected is a mutation, so it must filter through + // the strict HasAccess before delegating - without this, Store would gain an unscoped + // bulk-delete endpoint (any staff could delete any product id in the system). + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.DeleteSelected(new List { "owned", "foreign" }); + + _productViewModelServiceMock.Verify( + s => s.DeleteSelected(It.Is>(ids => ids.Count == 1 && ids[0] == "owned")), Times.Once); + } + + [TestMethod] + public async Task DeleteSelected_AllProductsScopeDenies_DoesNotCallDeleteSelected() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.DeleteSelected(new List { "foreign" }); + + _productViewModelServiceMock.Verify(s => s.DeleteSelected(It.IsAny>()), Times.Never); + } + + // --- GoToSku ----------------------------------------------------------------------------------------- + // Deliberate behavior tightening vs. Store's pre-refactor GoToSku (see the TODO in + // BaseProductController.GoToSku): denial now redirects to List, not Edit. + + [TestMethod] + public async Task GoToSku_ProductNotFound_ShowsWarningAndRedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync((Product)null); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task GoToSku_ScopeDeniesAccess_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task GoToSku_ScopeGrantsAccess_RedirectsToEdit() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + } + + // --- List / Create default store-scoping ----------------------------------------------------------- + + [TestMethod] + public async Task List_CallsPrepareProductListModel() + { + // PrepareProductListModel() now reads scope.DefaultStoreId internally (Task 9) - the controller + // no longer threads it through as an argument. + _productViewModelServiceMock.Setup(s => s.PrepareProductListModel()).ReturnsAsync(new ProductListModel()); + + var result = await _controller.List(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductListModel(), Times.Once); + } + + [TestMethod] + public async Task Create_Get_DefaultsModelStoreIdFromScope() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await _controller.Create() as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("store-1", model.StoreId); + } + + // --- LoadProductFriendlyNames -------------------------------------------------------------------- + // Filters the display list rather than denying the whole request: matches Store's CanAccessProduct + // loop and Vendor's HasAccessToProduct loop, both of which skip inaccessible products silently. + + [TestMethod] + public async Task LoadProductFriendlyNames_EmptyInput_ReturnsEmptyText() + { + var result = await _controller.LoadProductFriendlyNames(""); + + var json = result as JsonResult; + Assert.IsNotNull(json); + Assert.AreEqual("", GetTextProperty(json.Value)); + _productServiceMock.Verify(s => s.GetProductsByIds(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LoadProductFriendlyNames_ScopeDeniesAccess_SkipsProduct() + { + var allowed = new Product { Id = "p1", Name = "Allowed" }; + var denied = new Product { Id = "p2", Name = "Denied" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "p1", "p2" }, true)) + .ReturnsAsync(new List { allowed, denied }); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + + var result = await _controller.LoadProductFriendlyNames("p1,p2"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + // Faithful port of Store/Vendor's original index-based comma logic: it decides whether to + // append ", " from the loop index (i != products.Count - 1), not from whether a name was + // actually appended, so skipping the last product still leaves a trailing ", ". Pre-existing + // quirk in both original hosts, not introduced by this migration - characterized, not fixed. + Assert.AreEqual("Allowed, ", GetTextProperty(json.Value)); + } + + [TestMethod] + public async Task LoadProductFriendlyNames_ScopeGrantsAccess_IncludesAllProducts() + { + var p1 = new Product { Id = "p1", Name = "First" }; + var p2 = new Product { Id = "p2", Name = "Second" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "p1", "p2" }, true)) + .ReturnsAsync(new List { p1, p2 }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); + + var result = await _controller.LoadProductFriendlyNames("p1,p2"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + Assert.AreEqual("First, Second", GetTextProperty(json.Value)); + } + + private static string GetTextProperty(object value) => + (string)value.GetType().GetProperty("Text")!.GetValue(value); + + // --- RequiredProductAddPopup ---------------------------------------------------------------------- + + [TestMethod] + public async Task RequiredProductAddPopup_CallsPrepareAddRequiredProductModel() + { + // PrepareAddRequiredProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel()) + .ReturnsAsync(new ProductModel.AddRequiredProductModel()); + + var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(), Times.Once); + Assert.AreEqual("input1", _controller.ViewBag.productIdsInput); + } + + // --- RequiredProductAddPopupList ------------------------------------------------------------------- + + [TestMethod] + public async Task RequiredProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRequiredProductModel(); + var result = await _controller.RequiredProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRequiredProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RequiredProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- ProductCategoryList ---------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. + + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + } + + // Guards against a real regression found in review: the denial message must be templated via + // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists + // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a + // Vendor call site for it. + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCategoryInsert --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + } + + // --- ProductCollectionList ------------------------------------------------------------------ + // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and + // Vendor's CheckAccessToProduct gating this action on both hosts. + + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + } + + // Guards against the same regression class found in "Product categories": the denial message must be + // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" + // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCollectionInsert ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); + } + + // --- ProductCollectionUpdate ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); + } + + // --- ProductCollectionDelete ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); + } + + // --- RelatedProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. + + [TestMethod] + public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RelatedProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() + { + // PrepareRelatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) + .ReturnsAsync(new ProductModel.AddRelatedProductModel()); + + var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRelatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); + } + + // --- RelatedProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel(); + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach related-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRelatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RelatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + // --- SimilarProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList above. + + [TestMethod] + public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- SimilarProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() + { + // PrepareSimilarProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) + .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + + var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddSimilarProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); + } + + // --- SimilarProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel(); + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddSimilarProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.SimilarProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + // --- BundleProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. + + [TestMethod] + public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- BundleProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); + } + + // --- BundleProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); + } + + // --- BundleProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() + { + // PrepareBundleProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); + + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddBundleProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); + } + + // --- BundleProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel(); + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- BundleProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddBundleProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.BundleProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } + + // --- CrossSellProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's + // original CrossSellProductList had no check at all. + + [TestMethod] + public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- CrossSellProductDelete ------------------------------------------------------------------- + // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original + // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task CrossSellProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); + } + + // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() + { + // PrepareCrossSellProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) + .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); + + var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddCrossSellProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); + } + + // --- CrossSellProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel(); + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach cross-sell-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddCrossSellProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.CrossSellProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + // --- RecommendedProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original + // RecommendedProductList had no check at all. Vendor's original signature also dropped the + // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. + + [TestMethod] + public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RecommendedProductDelete ------------------------------------------------------------------- + // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original + // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task RecommendedProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); + } + + // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() + { + // PrepareRecommendedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) + .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); + + var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRecommendedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); + } + + // --- RecommendedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel(); + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach recommended-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRecommendedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RecommendedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } + + // --- AssociatedProductList ----------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no + // check at all. + + [TestMethod] + public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", "", "", true)) + .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- AssociatedProductUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductUpdate_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual(5, associatedProduct.DisplayOrder); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); + } + + // --- AssociatedProductDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductDelete_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); + } + + // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() + { + // PrepareAssociatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) + .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); + + var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddAssociatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); + } + + // --- AssociatedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel(); + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ + // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership + // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) + // service already filtered each selected product via HasAccessToProduct before reparenting it + // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent + // was never checked anywhere, letting a vendor attach their own products under another vendor's + // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching + // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, + // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is + // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the + // controller must enforce both checks itself. + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() + { + var parent = new Product { Id = "p1" }; + var selected = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() + { + var parent = new Product { Id = "p1" }; + var allowed = new Product { Id = "a1" }; + var denied = new Product { Id = "a2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); + _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + var denied = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + var reprepared = new ProductModel.AddAssociatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.AssociatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + // --- Product pictures --------------------------------------------------------------------------- + // ProductPictureAdd is deliberately not covered here (same rationale as Store's original + // ProductControllerTests): reaching its HasAccess check requires a non-empty IFormFileCollection and + // a prior Pictures-permission check via IPermissionService, disproportionate setup for what is + // otherwise the same one-line HasAccess condition covered everywhere else in this region. + + // --- ProductPictureList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) + .ReturnsAsync(new List { new() { Id = "pic1" } }); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPicturePopup (GET) ------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product picture not exist", content.Content); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { Id = "pic1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) + .ReturnsAsync((model, (Picture)null)); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + } + + // --- ProductPicturePopup (POST) ------------------------------------------------------------------ + + [TestMethod] + public async Task ProductPicturePopupPost_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, + // letting any vendor rename/re-alt-text another vendor's product picture by posting its + // productId/model.Id. + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() + { + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPicturePopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductPictureDelete ------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting + // any vendor delete another vendor's product picture by posting its productId/model.Id. + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } + + // --- Product specification attributes ----------------------------------------------------------- + // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' + // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. + + // --- ProductSpecAttrList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) + .ReturnsAsync(new List { new() { Id = "psa1" } }); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + var json = (JsonResult)result; + var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; + Assert.AreEqual(1, grid.Total); + } + + // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.IsTrue(model.ShowOnProductPage); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() + { + var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.AreEqual("psa1", model.Id); + } + + // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, + // letting any vendor add/edit specification attributes on another vendor's product by posting + // its id. + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() + { + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductSpecAttrDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting + // any vendor delete another vendor's specification attribute mapping by posting its + // productId/model.Id. + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() + { + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- Purchased with order ------------------------------------------------------------------------ + // Covers Admin and Store only - Vendor cannot bind to this signature (see the region comment in + // BaseProductController.cs: Vendor has its own, structurally different IOrderViewModelService and + // OrderListModel types). + + [TestMethod] + public async Task PurchasedWithOrders_PermissionDenied_ReturnsEmptyGrid_DoesNotLoadProduct() + { + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); + var orderViewModelServiceMock = new Mock(); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + Assert.IsNull(gridModel.Data); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PurchasedWithOrders_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var orderViewModelServiceMock = new Mock(); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + orderViewModelServiceMock.Verify( + s => s.PrepareOrderModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PurchasedWithOrders_ScopeGrantsAccess_UsesDefaultStoreIdAndReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + // DefaultStoreId stands in for Store's original model.StoreId = StaffStoreId (and for Admin's + // original, which left StoreId unset/null - GlobalAdminDataScope.DefaultStoreId is null). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var orders = new List { new() { Id = "order1" } }; + var orderViewModelServiceMock = new Mock(); + orderViewModelServiceMock + .Setup(s => s.PrepareOrderModel( + It.Is(m => m.ProductId == "p1" && m.StoreId == "store-1"), 1, 10)) + .ReturnsAsync((orders, orders.Count)); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1", + orderViewModelServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- Reviews --------------------------------------------------------------------------------- + + [TestMethod] + public async Task Reviews_ScopeDeniesAccess_ReturnsErrorJson_DoesNotLoadReviews() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var productReviewServiceMock = new Mock(); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Reviews_ScopeGrantsAccess_WithDefaultStoreId_FiltersReviewsByStore() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + // DefaultStoreId stands in for Store's original storeId argument (the staff member's + // StaffStoreId, used to filter reviews to that store). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var review = new ProductReview { Id = "r1", ProductId = "p1" }; + var reviews = new PagedList(new List { review }, 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "store-1", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + _productViewModelServiceMock.Verify( + s => s.PrepareProductReviewModel(It.IsAny(), review, false, true), Times.Once); + } + + [TestMethod] + public async Task Reviews_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyStoreId() + { + // Matches both Admin's and Vendor's originals, which both passed "" literally (Vendor scopes by + // VendorId via the HasAccess check above, not by store - VendorProductDataScope.DefaultStoreId + // is null). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var reviews = new PagedList(new List(), 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue), Times.Once); + } + + // --- Export / Import --------------------------------------------------------------------------- + + [TestMethod] + public async Task ExportExcelAll_Success_ReturnsXlsxFile() + { + var model = new ProductListModel(); + var products = new List { new() { Id = "p1" } }; + // vendorId is forced inside PrepareProducts itself; storeId is forced at the call site below, + // same as every other PrepareProductsModel/PrepareProducts caller in this controller. + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync(products); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(products)).ReturnsAsync([1, 2, 3]); + + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + Assert.AreEqual("text/xls", file.ContentType); + Assert.AreEqual("products.xlsx", file.FileDownloadName); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, file.FileContents); + } + + [TestMethod] + public async Task ExportExcelAll_UsesScopeDefaultStoreId() + { + // Regression test: found during a post-Phase-2 tenant-isolation audit that this action was the + // one PrepareProducts/PrepareProductsModel caller in the controller NOT forcing + // model.SearchStoreId = scope.DefaultStoreId, letting a Store-scoped user export other stores' + // products by posting an arbitrary SearchStoreId. + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var model = new ProductListModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + + await _controller.ExportExcelAll(model, exportManagerMock.Object); + + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task ExportExcelAll_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var model = new ProductListModel { SearchStoreId = "explicit" }; + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>())).ReturnsAsync([1, 2, 3]); + + await _controller.ExportExcelAll(model, exportManagerMock.Object); + + Assert.AreEqual("explicit", model.SearchStoreId); + } + + [TestMethod] + public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() + { + var model = new ProductListModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>())) + .ThrowsAsync(new Exception("boom")); + + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ExportExcelSelected_NullSelectedIds_ExportsEmptyList() + { + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()))) + .ReturnsAsync([9]); + + var result = await _controller.ExportExcelSelected(null, exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), true), Times.Never); + } + + [TestMethod] + public async Task ExportExcelSelected_FiltersOutProductsScopeDenies() + { + // Mirrors Vendor's original explicit HasAccessToProduct re-check on selectedIds (caller-supplied, + // not derived from a scoped search, unlike ExportExcelAll) - applied unconditionally here so + // Admin (where the original had no check at all) gets the same protection. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync([owned, foreign]); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + var exportManagerMock = new Mock>(); + exportManagerMock + .Setup(e => e.Export(It.Is>(p => p.Single() == owned))) + .ReturnsAsync([7]); + + var result = await _controller.ExportExcelSelected("owned,foreign", exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + exportManagerMock.Verify(e => e.Export(It.Is>(p => p.Single() == owned)), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_EmptyFile_DoesNotImport_RedirectsToListWithError() + { + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(0); + var importManagerMock = new Mock>(); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.Common.UploadFile"), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_NullFile_DoesNotImport_RedirectsToListWithError() + { + var importManagerMock = new Mock>(); + + var result = await _controller.ImportExcel(null, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ImportExcel_ValidFile_ImportsAndRedirectsToListWithSuccess() + { + using var stream = new MemoryStream([1, 2, 3]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(3); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).Returns(Task.CompletedTask); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(stream), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Imported"), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_ImportThrows_RedirectsToListWithError() + { + using var stream = new MemoryStream([1]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(1); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).ThrowsAsync(new Exception("bad file")); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + // --- Bulk editing -------------------------------------------------------------------------------- + + [TestMethod] + public async Task BulkEdit_CallsPrepareBulkEditListModel() + { + // PrepareBulkEditListModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel()) + .ReturnsAsync(new BulkEditListModel()); + + var result = await _controller.BulkEdit(); + + Assert.IsInstanceOfType(result, typeof(ViewResult)); + _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(), Times.Once); + } + + [TestMethod] + public async Task BulkEditSelect_StoreScoped_SetsSearchStoreIdFromScope() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new BulkEditListModel(); + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task BulkEditSelect_NotStoreScoped_LeavesSearchStoreIdUntouched() + { + var model = new BulkEditListModel { SearchStoreId = "preset" }; + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("preset", model.SearchStoreId); + } + + [TestMethod] + public async Task BulkEditUpdate_NullProducts_DoesNotCallService() + { + var result = await _controller.BulkEditUpdate(null); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditUpdate_FiltersOutProductsScopeDenies() + { + // Regression guard: Admin's original had no ownership check at all on this bulk-mutate endpoint + // (any client-supplied id list was updated unconditionally). Store's original filtered via + // FilterValidProductsForStore/CanAccessProduct; Vendor's original filtered via + // HasAccessToProduct inside its own service. Routing through scope.HasAccess here reproduces + // that per-item gate uniformly. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + var ownedModel = new BulkEditProductModel { Id = "owned" }; + var foreignModel = new BulkEditProductModel { Id = "foreign" }; + + await _controller.BulkEditUpdate(new List { ownedModel, foreignModel }); + + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); + } + + [TestMethod] + public async Task BulkEditUpdate_AllProductsScopeDenies_DoesNotCallUpdateBulkEdit() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditUpdate(new List { new() { Id = "foreign" } }); + + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditDelete_NullProducts_DoesNotCallService() + { + var result = await _controller.BulkEditDelete(null); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditDelete_FiltersOutProductsScopeDenies() + { + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditDelete(new List { + new() { Id = "owned" }, new() { Id = "foreign" } + }); + + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); + } + + [TestMethod] + public async Task BulkEditDelete_AllProductsScopeDenies_DoesNotCallDeleteBulkEdit() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditDelete(new List { new() { Id = "foreign" } }); + + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCall() + { + var result = await _controller.BulkEditUpdate(new List { new() { Id = "" } }); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } + + // --- ProductPriceList ------------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it + // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on + // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List + // (Insert/Update/Delete had no check at all). + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPriceInsert ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( + pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); + } + + // --- ProductPriceUpdate ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); + } + + // --- ProductPriceDelete ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); + } + + // --- TierPriceList ---------------------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership + // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on + // any product by id. Store's original never checked TierPriceEditPopup(GET) either. + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + // PrepareTierPriceModel(product) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var tierPrices = new List { new() { Id = "tp1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Get_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var result = await _controller.TierPriceCreatePopup("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify( + s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.TierPriceModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); + } + + // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ + // HasAccess added here: Store's original had no ownership check on this GET action at all. + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your product", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Empty tier price", content.Content); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); + } + + // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); + } + + // --- TierPriceDelete ---------------------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); + } + + // --- ProductAttributeMappingList ------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Store's original checked ownership + // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on + // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on + // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or + // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on + // any product, not just their own, by posting a known productId. + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attributes = new List { new() { Id = "pam1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) + .ReturnsAsync(attributes); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); + } + + // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- + // HasAccess added here: Vendor's original had no ownership check on this POST at all. + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeMappingModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() + { + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + // --- ProductAttributeMappingDelete ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeMappingDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- + // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a + // store/vendor user could update an attribute mapping's validation rules on any product by posting a + // known productId/model.Id. + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- + // HasAccess added uniformly. Admin's original had no ownership check on either action of this + // region at all. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) + .ReturnsAsync(new ProductAttributeConditionModel()); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), + Times.Once); + } + + // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on + // any product by posting a known productId/productAttributeMappingId. Admin's original had no + // check on either action. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeConditionModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), + Times.Once); + } + + // --- EditAttributeValues (GET) ------------------------------------------------------------------ + // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. + + [TestMethod] + public async Task EditAttributeValues_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; + var product = new Product { Id = "p1", Name = "Product 1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) + .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.ProductAttributeValueListModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + Assert.AreEqual("Product 1", model.ProductName); + Assert.AreEqual("Color", model.ProductAttributeName); + Assert.AreEqual("pam1", model.ProductAttributeMappingId); + } + + // --- ProductAttributeValueList (POST) ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var values = new List { new() { Id = "pav1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) + .ReturnsAsync(values); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); + } + + // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- + // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is + // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied + // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's + // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level + // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped + // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() + { + // The host/action combination this row's fix actually protects: Vendor losing its + // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in + // BaseProductController.ProductAttributeValueCreatePopup(POST). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) + .ReturnsAsync(model); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did); Admin's original had no check on either action. + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueEditPopup("missing", model)); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueDelete ----------------------------------------------------------------- + // HasAccess added here: this action takes only simple string parameters, so no model-level validator + // ever runs for it. Admin's original had no ownership check at all. + + [TestMethod] + public async Task ProductAttributeValueDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); + + var result = await _controller.AssociateProductToAttributeValuePopup(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), + Times.Once); + } + + // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() + { + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { SearchStoreId = "preset" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("preset", model.SearchStoreId); + } + + // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- + // HasAccess added here: Admin's original had no ownership check on the associated product at all. + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "missing" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Cannot load a product", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + Assert.IsInstanceOfType(result); + var content = result as ContentResult; + Assert.AreEqual("", content.Content); + } + + // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationDelete ------------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); + } + + // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel { Id = "c1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) + .ReturnsAsync(model); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("missing", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List()); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List { "warning" }); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + CollectionAssert.Contains(model.Warnings.ToList(), "warning"); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- GenerateAllAttributeCombinations --------------------------------------------------------------- + + [TestMethod] + public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); + } + + // --- ClearAllAttributeCombinations ----------------------------------------------------------------- + + [TestMethod] + public async Task ClearAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ClearAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() + { + var product = new Product + { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; + combination.TierPrices.Add(tierPrice); + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); + } + + // --- Reservation ------------------------------------------------------------------------------- + // ARCH-001 Phase 1 Task 8 row 23. Admin's originals had no ownership check at all on any of these + // four actions; Store used CanAccessProduct; Vendor used CheckAccessToProduct (List) or a combined + // null-or-HasAccessToProduct throw (the other three) - all normalized to scope.HasAccess. + + private static ProductReservation NewReservation(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListReservations ---------------------------------------------------------------------------- + + [TestMethod] + public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservations = new PagedList( + new List { NewReservation("r1", "p1") }, 0, 10); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) + .ReturnsAsync(reservations); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- GenerateCalendar ------------------------------------------------------------------------------ + + private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { + StartDate = new DateTime(2026, 1, 1), + EndDate = new DateTime(2026, 1, 1), + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 18, 0, 0), + Interval = 1, + IntervalUnit = (int)unit, + Quantity = 1, + Resource = "room1", + // 2026-01-01 is a Thursday. + Thursday = true + }; + + [TestMethod] + public async Task GenerateCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateCalendar("missing", ValidCalendarModel())); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _controller.ModelState.AddModelError("Resource", "Required"); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() + { + var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.Is(r => + r.ProductId == "p1" && r.Resource == "room1")), Times.Once); + _productServiceMock.Verify( + s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); + } + + // --- ClearCalendar ---------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); + } + + [TestMethod] + public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success + // callback for this action never reads the response body (no else branch) on any of the three + // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); + + var result = await _controller.ClearCalendar("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + // --- ClearOld --------------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearOld_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); + } + + [TestMethod] + public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer + // gap applies to this action's response handling. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; + var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList( + new List { oldReservation, futureReservation }, 0, int.MaxValue)); + + var result = await _controller.ClearOld("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); + } + + // --- ProductReservationDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductReservationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the + // access check ran against model.ProductId while the delete ran against model.ReservationId, with no + // check that the two referred to the same product. A caller with access to *any* product could pass + // that product's id for the access check while supplying the ReservationId of a reservation belonging + // to a different, unowned product, deleting it. + [TestMethod] + public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignReservation = NewReservation("r1", "p2"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1", "order1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + // --- Bids ---------------------------------------------------------------------------------------- + // ARCH-001 Phase 1 Task 8 row 24 (final region). Admin's originals had no ownership check at all; + // Store used CanAccessProduct; Vendor used a combined null-or-HasAccessToProduct throw - all + // normalized to scope.HasAccess. + + private static Bid NewBid(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListBids -------------------------------------------------------------------------------- + + [TestMethod] + public async Task ListBids_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); + } + + [TestMethod] + public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListBids_ScopeGrantsAccess_ReturnsBids() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.ListBids( + new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); + + Assert.IsInstanceOfType(result); + var json = result as JsonResult; + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + } + + // --- BidDelete --------------------------------------------------------------------------------- + + [TestMethod] + public async Task BidDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); + } + + [TestMethod] + public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by + // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while + // the delete ran against model.BidId, with no check that the two referred to the same product. A + // caller with access to *any* product could pass that product's id for the access check while + // supplying the BidId of a bid belonging to a different, unowned product, deleting it. + [TestMethod] + public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignBid = NewBid("b1", "p2"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1", "order1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); + } + + [TestMethod] + public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual("Bid not exists", gridModel.Errors); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs new file mode 100644 index 000000000..1c6e16d51 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs @@ -0,0 +1,39 @@ +using Grand.Domain.Permissions; +using Grand.Web.Admin.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeAdmin]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Admin's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +[TestClass] +public class ProductControllerAttributesTests +{ + [TestMethod] + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + { + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), "Missing [AuthorizeAdmin]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs deleted file mode 100644 index 89e99b7f7..000000000 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Web.Admin.Controllers; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.Common.Localization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; - -namespace Grand.Web.Admin.Tests.Controllers; - -// Characterization tests locking down the baseline for the planned ProductController consolidation: -// unlike Store/Vendor, Admin performs no ownership/scope check at all - any product can be deleted by -// any admin. If a shared base class is introduced later, this must stay true for Admin. -[TestClass] -public class ProductControllerTests -{ - private ProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - - [TestInitialize] - public void Setup() - { - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _controller = new ProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - new Mock().Object, - _translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object); - - var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_AnyExistingProduct_DeletesWithoutOwnershipCheck() - { - // No IContextAccessor is even injected here - unlike Store/Vendor, Admin has no notion of - // "not your product". A product limited to a store it has no relation to must still delete. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add("some-other-store"); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } -} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs new file mode 100644 index 000000000..e14f3bfbd --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs @@ -0,0 +1,45 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class GlobalAdminDataScopeTests +{ + [TestMethod] + public async Task HasAccess_AlwaysReturnsTrue() + { + var scope = new GlobalAdminDataScope(); + var result = await scope.HasAccess(new Product()); + Assert.IsTrue(result); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new GlobalAdminDataScope(); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + var scope = new GlobalAdminDataScope(); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void ShowStoreSelector_IsTrue() + { + var scope = new GlobalAdminDataScope(); + Assert.IsTrue(scope.ShowStoreSelector); + } + + [TestMethod] + public void CanFeatureOnHomepage_IsTrue() + { + var scope = new GlobalAdminDataScope(); + Assert.IsTrue(scope.CanFeatureOnHomepage); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 956f0b515..421b06294 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -22,7 +22,12 @@ using Grand.Domain.Shipping; using Grand.Domain.Stores; using Grand.Domain.Tax; +using Grand.Domain.Vendors; using Grand.Infrastructure; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Services; using Grand.Web.Common.Localization; @@ -40,22 +45,32 @@ public class ProductViewModelServiceTests private Mock _discountServiceMock; private Mock _enumTranslationServiceMock; + private Mock _groupServiceMock; private Mock _measureServiceMock; + private Mock _productServiceMock; private ProductViewModelService _productViewModelService; + private Mock> _scopeMock; private Mock _storeServiceMock; private Mock _taxCategoryServiceMock; private Mock _translationServiceMock; + private Mock _vendorServiceMock; private Mock _warehouseServiceMock; [TestInitialize] public void Setup() { + var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); + AutoMapperConfig.Init(mapperConfig); + _discountServiceMock = new Mock(); _enumTranslationServiceMock = new Mock(); + _groupServiceMock = new Mock(); _measureServiceMock = new Mock(); + _productServiceMock = new Mock(); _storeServiceMock = new Mock(); _taxCategoryServiceMock = new Mock(); _translationServiceMock = new Mock(); + _vendorServiceMock = new Mock(); _warehouseServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); @@ -67,6 +82,7 @@ public void Setup() var currencyServiceMock = new Mock(); currencyServiceMock.Setup(c => c.GetCurrencyById(It.IsAny())).ReturnsAsync((Currency)null); + currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); _measureServiceMock.Setup(m => m.GetMeasureWeightById(It.IsAny())).ReturnsAsync((MeasureWeight)null); _measureServiceMock.Setup(m => m.GetMeasureDimensionById(It.IsAny())) .ReturnsAsync((MeasureDimension)null); @@ -94,12 +110,23 @@ public void Setup() _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + _groupServiceMock.Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList()); + _enumTranslationServiceMock .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new SelectList(Enumerable.Empty())); + // Default: Admin's Global scope - no default store/vendor, homepage option and store dropdown both show. + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); + _scopeMock.Setup(s => s.CanFeatureOnHomepage).Returns(true); + _productViewModelService = new ProductViewModelService( - new Mock().Object, + _productServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -111,12 +138,12 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + _vendorServiceMock.Object, _translationServiceMock.Object, productLayoutServiceMock.Object, new Mock().Object, contextAccessorMock.Object, - new Mock().Object, + _groupServiceMock.Object, _warehouseServiceMock.Object, deliveryDateServiceMock.Object, _taxCategoryServiceMock.Object, @@ -135,7 +162,8 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - _enumTranslationServiceMock.Object); + _enumTranslationServiceMock.Object, + _scopeMock.Object); } [TestMethod] @@ -179,11 +207,12 @@ public async Task PrepareProductModel_UseModelStoreIdForWarehouses() [TestMethod] public async Task PrepareProductListModel_UseStoreIdForWarehouses() { + _scopeMock.Setup(s => s.DefaultStoreId).Returns(StaffStoreId); _warehouseServiceMock .Setup(w => w.GetAllWarehouses(StaffStoreId, It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedList { new() { Id = "warehouseId", Name = "Main" } }); - var model = await _productViewModelService.PrepareProductListModel(StaffStoreId); + var model = await _productViewModelService.PrepareProductListModel(); _warehouseServiceMock.Verify(w => w.GetAllWarehouses(StaffStoreId, It.IsAny(), It.IsAny()), Times.Once); @@ -193,14 +222,377 @@ public async Task PrepareProductListModel_UseStoreIdForWarehouses() [TestMethod] public async Task PrepareProductListModel_FilterStoresByStoreId() { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { new() { Id = "store1", Shortcut = "Store 1" }, new() { Id = "store2", Shortcut = "Store 2" } }); - var model = await _productViewModelService.PrepareProductListModel("store1"); + var model = await _productViewModelService.PrepareProductListModel(); Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1")); Assert.IsFalse(model.AvailableStores.Any(x => x.Value == "store2")); } + + [TestMethod] + public async Task PrepareProductListModel_GlobalScope_IncludesHomepageOptionAndStoreDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = await _productViewModelService.PrepareProductListModel(); + + Assert.IsTrue(model.AvailablePublishedOptions.Any(x => x.Value == "3"), + "Admin should offer the 'Show on homepage' option."); + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), + "Admin should offer a store dropdown."); + } + + [TestMethod] + public async Task PrepareProductListModel_VendorScope_HidesHomepageOptionAndStoreDropdown() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _scopeMock.Setup(s => s.CanFeatureOnHomepage).Returns(false); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = await _productViewModelService.PrepareProductListModel(); + + Assert.IsFalse(model.AvailablePublishedOptions.Any(x => x.Value == "3"), + "Vendor can't feature products on the homepage."); + Assert.IsFalse(model.AvailableStores.Any(), + "Vendor doesn't pick stores - no dropdown should be populated at all."); + } + + [TestMethod] + public async Task PrepareTierPriceModel_VendorScope_HidesStoreDropdown() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productViewModelService.PrepareTierPriceModel(model); + + Assert.IsFalse(model.AvailableStores.Any(), + "Vendor doesn't pick stores - no dropdown should be populated at all, matching the original Vendor service's PrepareTierPriceModel."); + } + + [TestMethod] + public async Task PrepareTierPriceModel_GlobalScope_IncludesStoreDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productViewModelService.PrepareTierPriceModel(model); + + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), + "Admin should offer a store dropdown for tier prices."); + } + + // --- Vendor-scoped product search (ARCH-001 Phase 1 Task 10) ------------------------------------ + + private void SetupSearchProducts() + { + _productServiceMock.Setup(p => p.SearchProducts( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((new PagedList(), new List())); + } + + private void VerifySearchProductsCalledWithVendorId(string expectedVendorId) + { + _productServiceMock.Verify(p => p.SearchProducts( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), expectedVendorId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task PrepareBulkEditProductModel_VendorScope_ForcesVendorFilter() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + await _productViewModelService.PrepareBulkEditProductModel(new BulkEditListModel(), 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareBulkEditProductModel_GlobalScope_DoesNotFilterByVendor() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + SetupSearchProducts(); + + await _productViewModelService.PrepareBulkEditProductModel(new BulkEditListModel(), 1, 10); + + VerifySearchProductsCalledWithVendorId(""); + } + + [TestMethod] + public async Task PrepareProductsModel_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductsModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareProductsModel_GlobalScope_UsesModelVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductsModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor2"); + } + + [TestMethod] + public async Task PrepareProducts_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProducts(model); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareProductModel_AddProductModel_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductModel.AddRelatedProductModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_VendorScope_HidesVendorDropdown() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + + var model = await _productViewModelService.PrepareRelatedProductModel(); + + Assert.IsFalse(model.AvailableVendors.Any(), + "Vendor's original PrepareAddProductModel never populated a vendor picker - the search is always forced to the current vendor."); + _vendorServiceMock.Verify(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_GlobalScope_IncludesVendorDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + _vendorServiceMock.Setup(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), true)) + .ReturnsAsync(new PagedList { new() { Id = "vendor1", Name = "Vendor 1" } }); + + var model = await _productViewModelService.PrepareRelatedProductModel(); + + Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor1"), + "Admin/Store should keep offering a vendor picker."); + } + + [TestMethod] + public async Task InsertProductModel_VendorScope_ForcesVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + var model = new ProductModel { Name = "Test product" }; + + var product = await _productViewModelService.InsertProductModel(model); + + Assert.AreEqual("vendor1", product.VendorId, + "Vendor's original InsertProductModel always set product.VendorId = CurrentVendor.Id - without " + + "this, a vendor-created product would map whatever (usually blank) VendorId the model carries " + + "and end up orphaned, inaccessible to the vendor who just created it."); + } + + [TestMethod] + public async Task InsertProductModel_GlobalScope_DoesNotForceVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + var model = new ProductModel { Name = "Test product", VendorId = "vendor2" }; + + var product = await _productViewModelService.InsertProductModel(model); + + Assert.AreEqual("vendor2", product.VendorId, + "Admin/Store should keep whatever vendor ownership the create form assigns."); + } + + [TestMethod] + public async Task UpdateProductModel_VendorScope_ForcesVendorId_PreventsCrossVendorReassignment() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + var product = new Product { Id = "p1", VendorId = "vendor1" }; + var model = new ProductModel { Id = "p1", VendorId = "vendor-attacker-supplied" }; + + var result = await _productViewModelService.UpdateProductModel(product, model); + + Assert.AreEqual("vendor1", result.VendorId, + "AdminShared's shared ProductProfile does not ignore ProductModel.VendorId -> Product.VendorId " + + "(Admin's edit form legitimately reassigns vendor ownership); without re-forcing it after mapping, " + + "a vendor could reassign their own product to a different vendor id via a tampered VendorId field."); + } + + [TestMethod] + public async Task UpdateProductModel_GlobalScope_UsesModelVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + var product = new Product { Id = "p1", VendorId = "vendor1" }; + var model = new ProductModel { Id = "p1", VendorId = "vendor2" }; + + var result = await _productViewModelService.UpdateProductModel(product, model); + + Assert.AreEqual("vendor2", result.VendorId, + "Admin should keep being able to reassign a product's vendor ownership via the edit form."); + } + + // --- Per-id ownership filter on Insert*ProductModel (ARCH-001 Phase 1 Task 11) ------------------ + // Mirrors Vendor's original InsertRelatedProductModel ("if (product == null || + // !HasAccessToProduct(product)) continue;") and the pattern already used for the selected-ids loop + // in BaseProductController.AssociatedProductAddPopup(POST) (Task 8). This gap was inert while Vendor + // wasn't yet subclassed onto BaseProductController; wiring Vendor in (Task 11) makes it live. + + private void SetupOwnershipForInsertTests(Product ownedProduct, Product notOwnedProduct) + { + _productServiceMock.Setup(p => p.GetProductById(ownedProduct.Id, It.IsAny())).ReturnsAsync(ownedProduct); + _productServiceMock.Setup(p => p.GetProductById(notOwnedProduct.Id, It.IsAny())).ReturnsAsync(notOwnedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(notOwnedProduct)).ReturnsAsync(false); + } + + [TestMethod] + public async Task InsertRelatedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRelatedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertRelatedProductModel(model); + + Assert.IsTrue(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertRelatedProduct(It.Is(r => r.ProductId2 == "not-owned"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InsertSimilarProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddSimilarProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertSimilarProductModel(model); + + Assert.IsTrue(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertSimilarProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never); + } + + [TestMethod] + public async Task InsertBundleProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddBundleProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertBundleProductModel(model); + + Assert.IsTrue(mainProduct.BundleProducts.Any(x => x.ProductId == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.BundleProducts.Any(x => x.ProductId == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertBundleProduct(It.Is(r => r.ProductId == "not-owned"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InsertCrossSellProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddCrossSellProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertCrossSellProductModel(model); + + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "owned")), Times.Once); + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } + + [TestMethod] + public async Task InsertRecommendedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRecommendedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertRecommendedProductModel(model); + + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "owned"), Times.Once); + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "not-owned"), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/RoutedProductDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/RoutedProductDataScopeTests.cs new file mode 100644 index 000000000..45d5b9d05 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/RoutedProductDataScopeTests.cs @@ -0,0 +1,104 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Services; + +/// +/// Covers the routing decision itself - which concrete IAdminDataScope<Product> the resolver +/// delegates to for each area, and that an unrecognized or missing area fails closed rather than +/// falling back to the unscoped global scope. +/// +[TestClass] +public class RoutedProductDataScopeTests +{ + private const string StaffStoreId = "store-1"; + private const string VendorId = "vendor-1"; + + private GlobalAdminDataScope _globalScope = null!; + private StoreAdminDataScope _storeScope = null!; + private VendorProductDataScope _vendorScope = null!; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + workContext.Setup(x => x.CurrentVendor).Returns(new Vendor { Id = VendorId }); + var contextAccessor = new Mock(); + contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + + _globalScope = new GlobalAdminDataScope(); + _storeScope = new StoreAdminDataScope(contextAccessor.Object); + _vendorScope = new VendorProductDataScope(contextAccessor.Object); + } + + private RoutedProductDataScope ResolverForArea(string? area) + { + var httpContext = new DefaultHttpContext(); + if (area is not null) httpContext.Request.RouteValues["area"] = area; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns(httpContext); + return new RoutedProductDataScope(httpContextAccessor.Object, _globalScope, _storeScope, _vendorScope); + } + + [TestMethod] + public void AdminArea_ResolvesToGlobalScope() + { + var resolver = ResolverForArea("Admin"); + Assert.AreEqual("Admin", resolver.ResourceKeyPrefix); + Assert.IsNull(resolver.DefaultStoreId); + Assert.IsNull(resolver.DefaultVendorId); + Assert.IsTrue(resolver.CanFeatureOnHomepage); + } + + [TestMethod] + public void StoreArea_ResolvesToStoreScope() + { + var resolver = ResolverForArea("Store"); + Assert.AreEqual("Admin", resolver.ResourceKeyPrefix); + Assert.AreEqual(StaffStoreId, resolver.DefaultStoreId); + Assert.IsNull(resolver.DefaultVendorId); + Assert.IsTrue(resolver.CanFeatureOnHomepage); + } + + [TestMethod] + public void VendorArea_ResolvesToVendorScope() + { + var resolver = ResolverForArea("Vendor"); + Assert.AreEqual("Vendor", resolver.ResourceKeyPrefix); + Assert.AreEqual(VendorId, resolver.DefaultVendorId); + Assert.IsFalse(resolver.ShowStoreSelector); + Assert.IsFalse(resolver.CanFeatureOnHomepage); + } + + [TestMethod] + public void MissingArea_Throws() + { + var resolver = ResolverForArea(null); + Assert.Throws(() => _ = resolver.ResourceKeyPrefix); + } + + [TestMethod] + public void UnrecognizedArea_Throws() + { + var resolver = ResolverForArea("Something"); + Assert.Throws(() => _ = resolver.ResourceKeyPrefix); + } + + [TestMethod] + public void NoHttpContext_Throws() + { + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns((HttpContext)null!); + var resolver = new RoutedProductDataScope(httpContextAccessor.Object, _globalScope, _storeScope, _vendorScope); + Assert.Throws(() => _ = resolver.ResourceKeyPrefix); + } +} diff --git a/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs b/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs new file mode 100644 index 000000000..89748bb9a --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs @@ -0,0 +1,14 @@ +// Stands in for a real Base*Controller living in Grand.Web.AdminShared.Controllers +// (this test project has no reference to Grand.Web.AdminShared) — only the +// namespace string matters to ViewLocationExpander.IsAdminSharedController. +namespace Grand.Web.AdminShared.Controllers +{ + public abstract class FakeBaseController { } +} + +namespace Grand.Web.Common.Tests.View +{ + public class FakeAdminSharedSubclass : Grand.Web.AdminShared.Controllers.FakeBaseController { } + + public class FakeUnrelatedController { } +} diff --git a/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs b/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs new file mode 100644 index 000000000..d6aad6812 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Grand.Web.Common.View; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.View; + +[TestClass] +public class ViewLocationExpanderTests +{ + private static ControllerActionDescriptor DescriptorFor(Type controllerType) => + new() { ControllerTypeInfo = controllerType.GetTypeInfo() }; + + [TestMethod] + public void IsAdminSharedController_TypeDerivesFromAdminSharedControllersNamespace_ReturnsTrue() + { + var descriptor = DescriptorFor(typeof(FakeAdminSharedSubclass)); + Assert.IsTrue(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_UnrelatedType_ReturnsFalse() + { + var descriptor = DescriptorFor(typeof(FakeUnrelatedController)); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_NonControllerActionDescriptor_ReturnsFalse() + { + var descriptor = new ActionDescriptor(); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs new file mode 100644 index 000000000..657d4e508 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs @@ -0,0 +1,39 @@ +using Grand.Domain.Permissions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Store.Tests.Controllers; + +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeStore]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Store's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +[TestClass] +public class ProductControllerAttributesTests +{ + [TestMethod] + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + { + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), "Missing [AuthorizeStore]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs index 11558b0ba..9d3b20680 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs @@ -2,30 +2,26 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; using Grand.Domain.Customers; -using Grand.Domain.Localization; -using Grand.Domain.Permissions; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Localization; using Grand.Web.Store.Controllers; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; namespace Grand.Web.Store.Tests.Controllers; -// Characterization tests for the store-scoping checks in ProductController, ahead of the planned -// consolidation of the near-duplicate ProductController copies in Grand.Web.Admin / Grand.Web.Store / -// Grand.Web.Vendor. Note the current redirect target on denial differs from Vendor's equivalent -// (Edit id= here vs List there) - that asymmetry must survive any refactor, or be called out as an -// intentional behavior change. +// Characterization test for ProductController.EditWarningCheck (ARCH-001 Phase 1 Task 11). This is the +// one piece of behavior newly hand-written for this task (re-derived from the original pre-migration +// Edit(GET) action's if/else, not copied) - everything else this controller does lives in, and is +// covered by, BaseProductControllerTests.cs (see that file's own header comment on the Task 13 +// consolidation this replaces). The condition is unusual (warns when NOT limited to stores at all, or +// when limited AND the staff member's store is one of several) and easy to get backwards, per the +// plan's own warning - this test exists so a future regression here fails loudly instead of silently. [TestClass] public class ProductControllerTests { @@ -33,1258 +29,84 @@ public class ProductControllerTests private const string OtherStoreId = "store-2"; private ProductController _controller; - private Mock _permissionServiceMock; private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; + private Mock> _scopeMock; [TestInitialize] public void Setup() { _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _permissionServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + var productViewModelServiceMock = new Mock(); + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); var workContextMock = new Mock(); workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); var contextAccessorMock = new Mock(); contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns(StaffStoreId); + _scopeMock.Setup(s => s.CanView(It.IsAny())).ReturnsAsync(true); + var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List()); _controller = new ProductController( - _productViewModelServiceMock.Object, + productViewModelServiceMock.Object, _productServiceMock.Object, new Mock().Object, - contextAccessorMock.Object, languageServiceMock.Object, - _translationServiceMock.Object, + translationServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, - _permissionServiceMock.Object, - new Mock().Object); + new Mock().Object, + new Mock().Object, + _scopeMock.Object); var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = httpContext }; _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); } - // --- Shared helpers for the CanAccessProduct denial tests below ------------------------------- - // Every action denies access via the same rule (AclMappingExtension.AccessToEntityByStore: an - // explicit single foreign store beats the staff member's store). Centralizing the "denied product" - // shape and the per-response-type assertions keeps each of the ~60 call sites below to a few lines, - // matching the mechanical nature of the CanAccessProduct extraction itself. - - private static Product ForeignProduct(string id = "denied") - { - var product = new Product { Id = id, LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - return product; - } - - private void MockAnyProductLookupAsForeign() - { - _productServiceMock.Setup(p => p.GetProductById(It.IsAny(), It.IsAny())) - .ReturnsAsync(ForeignProduct()); - } - - private void MockSkuLookupAsForeign() - { - _productServiceMock.Setup(p => p.GetProductBySku(It.IsAny())).ReturnsAsync(ForeignProduct()); - } - - private static void AssertKendoGridPermissionError(IActionResult result) - { - var json = result as JsonResult; - Assert.IsNotNull(json, "expected a JsonResult"); - var data = json.Value as DataSourceResult; - Assert.IsNotNull(data, "expected a DataSourceResult"); - Assert.AreEqual("resource", data.Errors); - } - - private static void AssertContentPermissionError(IActionResult result) - { - var content = result as ContentResult; - Assert.IsNotNull(content, "expected a ContentResult"); - Assert.AreEqual("resource", content.Content); - } - - private static void AssertJsonErrorsPermissionError(IActionResult result) - { - var json = result as JsonResult; - Assert.IsNotNull(json, "expected a JsonResult"); - var errorsProp = json.Value?.GetType().GetProperty("errors"); - Assert.IsNotNull(errorsProp, "expected an anonymous object with an 'errors' property"); - Assert.AreEqual("resource", errorsProp.GetValue(json.Value)); - } - - private static void AssertRedirectToProductList(IActionResult result) - { - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect, "expected a RedirectToActionResult"); - Assert.AreEqual("List", redirect.ActionName); - Assert.AreEqual("Product", redirect.ControllerName); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOutsideStaffStore_RedirectsToEditWithoutDeleting() - { - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - // Unlike Vendor, denial here redirects back to Edit rather than List. - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("p1", redirect.RouteValues["id"]); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductInStaffStore_DeletesAndRedirectsToList() - { - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } + private bool WarningWasRaised() + => _controller.TempData["grand.notifications.Warning"] is List { Count: > 0 }; [TestMethod] - public async Task Delete_ProductNotLimitedToAnyStore_IsDenied() + public async Task EditGet_ProductNotLimitedToAnyStore_RaisesWarning() { - // Counter-intuitive but current behavior: AccessToEntityByStore only grants access when - // LimitedToStores is true AND the product belongs to exactly one store (this one). A - // "global" (LimitedToStores=false) product is therefore NOT deletable by store staff - - // see AclMappingExtension.AccessToEntityByStore. A refactor must not silently "fix" this. var product = new Product { Id = "p1", LimitedToStores = false }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductInMultipleStoresIncludingStaffStore_IsDenied() - { - // Same source: Stores.Count == 1 is required, so a product shared across stores is denied - // even to a staff member of one of those stores. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Edit(new ProductModel { Id = "missing" }, continueEditing: false); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductOutsideStaffStore_RedirectsToEditWithoutUpdating() - { - // Same check, same "Edit" redirect target as Delete - but note this is a *different* check - // from Edit(GET), which additionally allows a multi-store product through with a warning. - // Do not fold this into a helper shared with Edit(GET). - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); + await _controller.Edit("p1"); - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("p1", redirect.RouteValues["id"]); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + Assert.IsTrue(WarningWasRaised(), "A product visible to every store must warn a store-scoped editor."); } [TestMethod] - public async Task EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning() + public async Task EditGet_ProductLimitedToStaffStoreAndAnotherStore_RaisesWarning() { - // Edit(GET)'s permissive branch: a product limited to more than one store, one of which is - // this staff member's store, is NOT denied here - it is shown with a warning instead. This is - // the one path that must stay outside any shared "authorize or redirect" helper. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - product.Stores.Add(OtherStoreId); + var product = new Product + { Id = "p1", LimitedToStores = true, Stores = [StaffStoreId, OtherStoreId] }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - var result = await _controller.Edit("p1"); + await _controller.Edit("p1"); - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), product, false, false), Times.Once); + Assert.IsTrue(WarningWasRaised(), + "A product shared with another store beyond the staff member's own must still warn."); } [TestMethod] - public async Task EditGet_ProductInSingleOtherStore_RedirectsToList() + public async Task EditGet_ProductLimitedToStaffStoreOnly_NoWarning() { - // The strict branch of Edit(GET) - a product limited to exactly one store that isn't this - // staff member's - as opposed to the permissive multi-store branch tested above. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); + var product = new Product { Id = "p1", LimitedToStores = true, Stores = [StaffStoreId] }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - var result = await _controller.Edit("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task GoToSku_ProductNotAccessible_RedirectsToEditWithoutExposingIt() - { - MockSkuLookupAsForeign(); - - var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("denied", redirect.RouteValues["id"]); - } - - [TestMethod] - public async Task LoadProductFriendlyNames_SkipsNamesOfProductsNotAccessible() - { - var owned = new Product { Id = "owned", Name = "Owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - var foreign = ForeignProduct("foreign"); - foreign.Name = "Foreign"; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) - .ReturnsAsync(new List { owned, foreign }); - - var result = await _controller.LoadProductFriendlyNames("owned,foreign"); - - // Note: the trailing ", " is current behavior, not intentional - the separator is appended - // based on loop position ("not the last id"), not on whether a name was actually appended for - // the *previous* id. Preserved as-is; not this refactor's concern to fix. - var json = result as JsonResult; - Assert.IsNotNull(json); - var text = json.Value.GetType().GetProperty("Text")?.GetValue(json.Value) as string; - Assert.AreEqual("Owned, ", text); - } - - // --- Product categories --------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCategoryList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCategoryInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryUpdate(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryDelete(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- Product collections ---------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionInsert(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionUpdate(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionDelete(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Related products -------------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductUpdate(new ProductModel.RelatedProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductDelete(new ProductModel.RelatedProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductAddPopup(new ProductModel.AddRelatedProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - // --- Similar products --------------------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductUpdate(new ProductModel.SimilarProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductDelete(new ProductModel.SimilarProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductAddPopup(new ProductModel.AddSimilarProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Bundle products ------------------------------------------------------------------------------ - - [TestMethod] - public async Task BundleProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductUpdate(new ProductModel.BundleProductModel { ProductBundleId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductDelete(new ProductModel.BundleProductModel { ProductBundleId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductAddPopup(new ProductModel.AddBundleProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Cross-sell products -------------------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task CrossSellProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductDelete(new ProductModel.CrossSellProductModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task CrossSellProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductAddPopup(new ProductModel.AddCrossSellProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Recommended products ------------------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RecommendedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductDelete(new ProductModel.RecommendedProductModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RecommendedProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductAddPopup(new ProductModel.AddRecommendedProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Associated products ---------------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AssociatedProductUpdate_AssociatedProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductUpdate(new ProductModel.AssociatedProductModel { Id = "p1" }); - - AssertKendoGridPermissionError(result); - _productServiceMock.Verify(s => s.UpdateAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductDelete(new ProductModel.AssociatedProductModel { Id = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopup_ParentProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductAddPopup(new ProductModel.AddAssociatedProductModel { - ProductId = "p1" - }); - - AssertContentPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopup_ParentOwnedButCandidateNotAccessible_ExcludesCandidate() - { - // The parent product is the vendor's own, but one selected candidate belongs to another - // store - AssociatedProductAddPopup filters SelectedProductIds down to only the accessible - // ones (the positive `CanAccessProduct(selected)` form) before calling InsertAssociatedProductModel. - var parent = new Product { Id = "parent", LimitedToStores = true }; - parent.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("parent", It.IsAny())).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var model = new ProductModel.AddAssociatedProductModel { - ProductId = "parent", - SelectedProductIds = ["foreign"] - }; - - var result = await _controller.AssociatedProductAddPopup(model); - - AssertSuccessContent(result); - _productViewModelServiceMock.Verify( - s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - private static void AssertSuccessContent(IActionResult result) - { - var content = result as ContentResult; - Assert.IsNotNull(content, "expected a ContentResult (success path, not the permission-denied one)"); - Assert.AreEqual("", content.Content); - } - - // --- Product pictures -------------------------------------------------------------------------- - // ProductPictureAdd is deliberately not covered here: reaching its CanAccessProduct check requires - // a non-empty IFormFileCollection and a prior Pictures-permission check, disproportionate setup for - // what is otherwise the same one-line condition covered everywhere else in this file. - - [TestMethod] - public async Task ProductPictureList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPictureList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ProductNotAccessible_Throws() - { - // Unlike its GET counterpart, the POST handler throws instead of returning an error response. - MockAnyProductLookupAsForeign(); - - try - { - await _controller.ProductPicturePopup(new ProductModel.ProductPictureModel { ProductId = "p1" }); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - [TestMethod] - public async Task ProductPictureDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPictureDelete(new ProductModel.ProductPictureModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Product specification attributes --------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrPopup( - new Mock().Object, "p1", null); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrPopup( - new Mock().Object, - new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrDelete(new ProductSpecificationAttributeModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Purchased with orders / Reviews ------------------------------------------------------------ - - [TestMethod] - public async Task PurchasedWithOrders_ProductNotAccessible_ReturnsKendoGridError() - { - _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); - MockAnyProductLookupAsForeign(); - - var result = await _controller.PurchasedWithOrders(new DataSourceRequest(), "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task Reviews_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.Reviews(new DataSourceRequest(), "p1", new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - // --- Bulk editing -------------------------------------------------------------------------------- - - [TestMethod] - public async Task BulkEditDelete_FiltersOutProductsNotAccessible() - { - var owned = new Product { Id = "owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("owned", It.IsAny())).ReturnsAsync(owned); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var models = new List { - new() { Id = "owned" }, - new() { Id = "foreign" } - }; - - await _controller.BulkEditDelete(models); - - _productViewModelServiceMock.Verify(s => s.DeleteBulkEdit( - It.Is>(list => list.Count == 1 && list[0].Id == "owned")), Times.Once); - } - - [TestMethod] - public async Task BulkEditUpdate_FiltersOutProductsNotAccessible() - { - var owned = new Product { Id = "owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("owned", It.IsAny())).ReturnsAsync(owned); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var models = new List { - new() { Id = "owned" }, - new() { Id = "foreign" } - }; - - await _controller.BulkEditUpdate(models); - - _productViewModelServiceMock.Verify(s => s.UpdateBulkEdit( - It.Is>(list => list.Count() == 1 && list.First().Id == "owned")), - Times.Once); - } - - // --- Product currency price ------------------------------------------------------------------------ - - [TestMethod] - public async Task ProductPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceInsert(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceUpdate(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceDelete(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Tier prices ----------------------------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceCreatePopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceCreatePopup(new ProductModel.TierPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceEditPopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceEditPopup("p1", new ProductModel.TierPriceModel()); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceDelete(new ProductModel.TierPriceDeleteModel("t1", "p1")); - - AssertKendoGridPermissionError(result); - } - - // --- Product attributes ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeMappingList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingPopupGet_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingPopup( - new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ProductNotAccessible_ReturnsKendoGridError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValidationRulesPopup("id1", "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeConditionPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeConditionPopupPost_ProductNotAccessible_ReturnsContentError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeConditionPopup( - new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task EditAttributeValues_ProductNotAccessible_ReturnsContentError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.EditAttributeValues("pam1", "p1", new Mock().Object); - - AssertContentPermissionError(result); - } - - // --- Product attribute values ------------------------------------------------------------------ - - [TestMethod] - public async Task ProductAttributeValueList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new DataSourceRequest()); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopupPost_ProductNotAccessible_RedirectsToProductList() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueCreatePopup( - new ProductModel.ProductAttributeValueModel { ProductId = "p1" }); - - AssertRedirectToProductList(result); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueEditPopup("val1", "p1", "pam1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopupPost_ProductNotAccessible_RedirectsToProductList() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueEditPopup("p1", - new ProductModel.ProductAttributeValueModel()); - - AssertRedirectToProductList(result); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ProductNotAccessible_Throws() - { - var foreign = ForeignProduct(); - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(new ProductAttributeValue { Id = "val1" }); - foreign.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - try - { - await _controller.ProductAttributeValueDelete("val1", "pam1", "p1", - new Mock().Object); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_AssociatedProductNotAccessible_Throws() - { - MockAnyProductLookupAsForeign(); - - try - { - await _controller.AssociateProductToAttributeValuePopup( - new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel { - AssociatedToProductId = "p1" - }); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - // --- Product attribute combinations --------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ProductNotAccessible_ReturnsKendoGridError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AttributeCombinationPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AttributeCombinationPopup("p1", new ProductAttributeCombinationModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "t1"); - - AssertContentPermissionError(result); - } - - // --- Reservation ---------------------------------------------------------------------------------- - - [TestMethod] - public async Task ListReservations_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ListReservations(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task GenerateCalendar_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.GenerateCalendar("p1", new ProductModel.GenerateCalendarModel()); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ClearCalendar_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearCalendar("p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ClearOld_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearOld("p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ProductReservationDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductReservationDelete(new ProductModel.ReservationModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Bids ----------------------------------------------------------------------------------------- - - [TestMethod] - public async Task ListBids_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ListBids(new DataSourceRequest(), "p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task BidDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BidDelete(new ProductModel.BidModel { ProductId = "p1" }); + await _controller.Edit("p1"); - AssertKendoGridPermissionError(result); + Assert.IsFalse(WarningWasRaised(), + "A product exclusive to the staff member's own store needs no cross-store warning."); } } diff --git a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs new file mode 100644 index 000000000..d14eadf12 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs @@ -0,0 +1,127 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Services; + +[TestClass] +public class StoreAdminDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string StaffStoreId = "store-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + // These three mirror AclMappingExtension.AccessToEntityByStore's existing, deliberately strict + // rule (src/Web/Grand.Web.AdminShared/Extensions/AclMappingExtension.cs), the same rule + // ProductController.CanAccessProduct already enforces for Edit(POST)/Delete/CopyProduct today + // (see src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs, + // Delete_ProductNotLimitedToAnyStore_IsDenied and + // Delete_ProductInMultipleStoresIncludingStaffStore_IsDenied, both commented + // "counter-intuitive but current behavior... must not silently fix this"). Access is granted + // ONLY when the product is limited to stores, is in exactly one store, and that store is the + // staff member's store — a global product or one shared across multiple stores is denied. + [TestMethod] + public async Task HasAccess_ProductNotLimitedToStores_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = false }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToOtherStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToStaffStoreOnly_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId] }; + + Assert.IsTrue(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductInMultipleStoresIncludingStaffStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual(StaffStoreId, scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + // Store has no distinct resource set for Product screens yet (see Task 6) — it renders + // AdminShared's "Admin.*" keys today, so the migrated scope must keep that behavior. + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void ShowStoreSelector_IsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(scope.ShowStoreSelector); + } + + [TestMethod] + public void CanFeatureOnHomepage_IsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(scope.CanFeatureOnHomepage); + } + + // CanView is deliberately looser than HasAccess: it mirrors Store's original Edit(GET)/CopyProduct + // rule (a global or multi-store product including the staff member's store may be viewed/copied; + // only a product limited to stores that exclude the staff member's store is denied). See the + // existing test comment in ProductControllerTests.EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning: + // "This is the one path that must stay outside any shared 'authorize or redirect' helper." + [TestMethod] + public async Task CanView_ProductNotLimitedToStores_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.CanView(new Product { LimitedToStores = false })); + } + + [TestMethod] + public async Task CanView_ProductInMultipleStoresIncludingStaffStore_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + Assert.IsTrue(await scope.CanView(product)); + } + + [TestMethod] + public async Task CanView_ProductLimitedToOtherStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + Assert.IsFalse(await scope.CanView(product)); + } +} diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs index ea727905a..bad6c6765 100644 --- a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs @@ -1,138 +1,44 @@ -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Domain.Vendors; -using Grand.Infrastructure; -using Grand.Web.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Controllers; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; namespace Grand.Web.Vendor.Tests.Controllers; -// Characterization tests for the tenant-isolation checks in ProductController, ahead of the planned -// consolidation of the near-duplicate ProductController/ProductViewModelService copies in -// Grand.Web.Admin / Grand.Web.Store / Grand.Web.Vendor. These lock down the *current* behavior -// (including the redirect target chosen on access denial) so the refactor has something to fail against. +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeVendor]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Vendor's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +// +// All scope/access-check behavior (including Vendor's own HasAccess semantics) is now covered by +// BaseProductControllerTests, parameterized over a mocked IAdminDataScope - see +// Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs. This file only keeps routing/ +// attribute-only coverage, mirroring Admin's and Store's ProductControllerAttributesTests.cs. [TestClass] public class ProductControllerTests { - private const string OwnVendorId = "vendor-1"; - private const string OtherVendorId = "vendor-2"; - - private ProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - - [TestInitialize] - public void Setup() - { - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentVendor).Returns(new Domain.Vendors.Vendor { Id = OwnVendorId }); - var contextAccessorMock = new Mock(); - contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); - - _controller = new ProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - contextAccessorMock.Object, - new Mock().Object, - _translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object); - - var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOwnedByAnotherVendor_RedirectsToListWithoutDeleting() - { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOwnedByCurrentVendor_DeletesAndRedirectsToList() - { - var product = new Product { Id = "p1", VendorId = OwnVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } - [TestMethod] - public async Task EditGet_ProductOwnedByAnotherVendor_RedirectsToListWithoutPreparingModel() + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductOwnedByAnotherVendor_RedirectsToListWithoutUpdating() - { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), "Missing [AuthorizeVendor]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs deleted file mode 100644 index a6306550d..000000000 --- a/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; -using Grand.Business.Core.Interfaces.Catalog.Directory; -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.Checkout.Shipping; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Seo; -using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Business.Core.Interfaces.Customers; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain; -using Grand.Domain.Catalog; -using Grand.Domain.Directory; -using Grand.Domain.Tax; -using Grand.Domain.Vendors; -using Grand.Infrastructure; -using Grand.Infrastructure.Mapper; -using Grand.Mapping; -using Grand.Web.Common.Localization; -using Grand.Web.Vendor.Mapper; -using Grand.Web.Vendor.Models.Catalog; -using Grand.Web.Vendor.Services; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; - -namespace Grand.Web.Vendor.Tests.Services; - -// Characterization tests for the vendor-specific behavior of this forked ProductViewModelService, ahead -// of the planned consolidation back into Grand.Web.AdminShared.Services.ProductViewModelService (the two -// classes are ~85% identical; the remainder is the tenant-isolation logic covered here). These tests must -// keep passing (or have their expectation deliberately revised in the same change) once the fork is -// removed and the AdminShared implementation is parameterized/extended for the Vendor area instead. -[TestClass] -public class ProductViewModelServiceTests -{ - private const string CurrentVendorId = "vendor-1"; - private const string OtherVendorId = "vendor-2"; - - private Mock _productServiceMock; - private Mock _seNameServiceMock; - private ProductViewModelService _service; - - [TestInitialize] - public void Setup() - { - var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); - AutoMapperConfig.Init(mapperConfig); - - _productServiceMock = new Mock(); - _seNameServiceMock = new Mock(); - _seNameServiceMock - .Setup(s => s.TranslationSeNameProperties(It.IsAny>(), - It.IsAny(), It.IsAny>>())) - .ReturnsAsync(new List()); - _seNameServiceMock - .Setup(s => s.ValidateSeName(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("se-name"); - - var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentVendor).Returns(new Grand.Domain.Vendors.Vendor { Id = CurrentVendorId }); - var contextAccessorMock = new Mock(); - contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); - - var translationServiceMock = new Mock(); - translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _service = new ProductViewModelService( - _productServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - contextAccessorMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new CurrencySettings(), - new MeasureSettings(), - new TaxSettings(), - _seNameServiceMock.Object, - new Mock().Object); - } - - [TestMethod] - public async Task InsertProductModel_SetsVendorIdFromCurrentVendor() - { - var model = new ProductModel { Name = "New product" }; - - var product = await _service.InsertProductModel(model); - - Assert.AreEqual(CurrentVendorId, product.VendorId); - _productServiceMock.Verify(p => p.InsertProduct(It.Is(x => x.VendorId == CurrentVendorId)), - Times.Once); - } - - [TestMethod] - public async Task PrepareProducts_FiltersSearchByCurrentVendor() - { - IPagedList paged = new PagedList { new() { Id = "p1", VendorId = CurrentVendorId } }; - _productServiceMock.Setup(p => p.SearchProducts( - false, 0, int.MaxValue, It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny(), CurrentVendorId, It.IsAny(), It.IsAny(), false, false, - It.IsAny(), It.IsAny(), null, null, "", It.IsAny(), false, true, false, "", - null, null, ProductSortingEnum.Position, true, It.IsAny())) - .ReturnsAsync((paged, (IList)null)); - - var products = await _service.PrepareProducts(new ProductListModel()); - - Assert.AreEqual(1, products.Count); - _productServiceMock.Verify(p => p.SearchProducts( - false, 0, int.MaxValue, It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny(), CurrentVendorId, It.IsAny(), It.IsAny(), false, false, - It.IsAny(), It.IsAny(), null, null, "", It.IsAny(), false, true, false, "", - null, null, ProductSortingEnum.Position, true, It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeleteSelected_SkipsProductsNotOwnedByCurrentVendor() - { - var own = new Product { Id = "own", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "own", "other" }, true)) - .ReturnsAsync(new List { own, other }); - - await _service.DeleteSelected(new[] { "own", "other" }); - - _productServiceMock.Verify(p => p.DeleteProduct(own), Times.Once); - _productServiceMock.Verify(p => p.DeleteProduct(other), Times.Never); - } - - [TestMethod] - public async Task InsertRelatedProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertRelatedProductModel(new ProductModel.AddRelatedProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.RelatedProducts.Any(x => x.ProductId2 == "other")); - _productServiceMock.Verify(p => p.InsertRelatedProduct(It.IsAny(), "source"), Times.Never); - } - - [TestMethod] - public async Task InsertSimilarProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - // Regression test for a fixed authorization bug: this used to check - // HasAccessToProduct(productId1) - the product already being edited, which the vendor is - // guaranteed to own - instead of HasAccessToProduct(product), the candidate being linked in - // via `id`. That made the check a no-op: any vendor could link any other vendor's product as - // "similar". Now it checks the candidate, matching InsertRelatedProductModel/ - // InsertBundleProductModel. - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertSimilarProductModel(new ProductModel.AddSimilarProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.SimilarProducts.Any(x => x.ProductId2 == "other")); - _productServiceMock.Verify(p => p.InsertSimilarProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task InsertSimilarProductModel_LinksCandidateOwnedByCurrentVendor() - { - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var own = new Product { Id = "own", VendorId = CurrentVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("own", false)).ReturnsAsync(own); - - await _service.InsertSimilarProductModel(new ProductModel.AddSimilarProductModel { - ProductId = "source", - SelectedProductIds = ["own"] - }); - - Assert.IsTrue(source.SimilarProducts.Any(x => x.ProductId2 == "own")); - _productServiceMock.Verify(p => p.InsertSimilarProduct(It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task InsertBundleProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - // Same fixed bug as InsertSimilarProductModel, same fix. - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertBundleProductModel(new ProductModel.AddBundleProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.BundleProducts.Any(x => x.ProductId == "other")); - _productServiceMock.Verify(p => p.InsertBundleProduct(It.IsAny(), It.IsAny()), - Times.Never); - } -} diff --git a/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs new file mode 100644 index 000000000..5546b8528 --- /dev/null +++ b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs @@ -0,0 +1,74 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Vendor.Tests.Services; + +[TestClass] +public class VendorProductDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string VendorId = "vendor-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentVendor).Returns(new Domain.Vendors.Vendor { Id = VendorId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + [TestMethod] + public async Task HasAccess_OwnProduct_ReturnsTrue() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.HasAccess(new Product { VendorId = VendorId })); + } + + [TestMethod] + public async Task HasAccess_OtherVendorsProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(new Product { VendorId = "vendor-2" })); + } + + [TestMethod] + public async Task HasAccess_NullProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(null!)); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsVendor() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); + } + + [TestMethod] + public void ShowStoreSelector_IsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(scope.ShowStoreSelector); + } + + [TestMethod] + public void CanFeatureOnHomepage_IsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(scope.CanFeatureOnHomepage); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml deleted file mode 100644 index 8a24cb767..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml +++ /dev/null @@ -1,100 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.EditProductDetails"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.EditProductDetails"] - @Model.Name - - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") - -
-
-
- - - - - - @Loc["Admin.Common.Delete"] - - -
-
-
-
- -
-
-
-
-
- - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml deleted file mode 100644 index 4e74e29ea..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ /dev/null @@ -1,252 +0,0 @@ -@using Grand.Domain.Media -@model ProductModel -@{ - ViewData["DownloadType"] = DownloadType.Product; - ViewData["ReferenceId"] = Model.Id; -} - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - - - - @Loc["Admin.Catalog.Products.Fields.RequiredProductIds.AddNew"] - - - - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml deleted file mode 100644 index e895f68f6..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-

@Loc["Admin.Catalog.Products.AssociatedProducts.Note2"]

-
- - - - -} -else -{ - @Loc["Admin.Catalog.Products.AssociatedProducts.SaveBeforeEdit"] -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml deleted file mode 100644 index 9f69a153a..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - -
- -
-
-
- -
- -} -else -{ -
- @Loc["Admin.Catalog.Products.Bids.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml deleted file mode 100644 index d024ae10d..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ /dev/null @@ -1,149 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.BundleProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml deleted file mode 100644 index d1aa6cae8..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ /dev/null @@ -1,376 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - -
-

@Loc["Admin.Catalog.Products.Calendar.Calendarconfiguration"]

-
-
-
- -
- -
-
- -
- -
-
- -
-
- -
- -
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- - - - -
-
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
-
@Loc["Admin.Catalog.Products.Calendar.WeekDay"]
-
-
-
-
- - - - - - - -
-
-
-
-
-
-
- -
-
-
-
- - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Calendar.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml deleted file mode 100644 index 00ddb4736..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Categories.Fields.Category"] -
-
-
-
- -
- -} -else -{ -
- @Loc["Admin.Catalog.Products.Categories.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml deleted file mode 100644 index 87a9e42f4..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Collections.Fields.Collection"] -
-
-
-
- -
- - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Collections.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml deleted file mode 100644 index e0e5d1d25..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.CrossSells"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.CrossSells.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml deleted file mode 100644 index a15fb8b3d..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml +++ /dev/null @@ -1,25 +0,0 @@ -@model ProductModel - -@if (Model.AvailableDiscounts is { Count: > 0 }) -{ -
- @foreach (var discount in Model.AvailableDiscounts) - { - - } -
-} -else -{ -
- @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"]) -
-} - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml deleted file mode 100644 index b8d7292a2..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml +++ /dev/null @@ -1,71 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -
- -
-
-
- - -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml deleted file mode 100644 index fc89e14d6..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ /dev/null @@ -1,278 +0,0 @@ -@model ProductModel - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- @if (Model.ProductWarehouseInventoryModels.Count > 0) - { - - - - - - - - - - - @for (var i = 0; i < Model.ProductWarehouseInventoryModels.Count; i++) - { - var item = Model.ProductWarehouseInventoryModels[i]; - - - - - - - } - -
- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] -
-
- @item.WarehouseName (@item.WarehouseCode) - -
-
- - - - - -
-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description1"] -

-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description2"] -

-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description3"] -

- } - else - { - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] - } -
-
-
- -
- - - - - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- [@Model.BaseWeightIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml deleted file mode 100644 index 74961f433..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ /dev/null @@ -1,165 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- -
- - -

- @Loc["Admin.Catalog.Products.Pictures.AddNew"] -

-
-
-
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaAdmin }); - ViewData["Click"] = "btnRefreshProductPictures"; - } -
- - -
- - -
-
-
-} -else -{ -
- @Loc["Admin.Catalog.Products.Pictures.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml deleted file mode 100644 index ea9eb19c0..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ /dev/null @@ -1,273 +0,0 @@ -@model ProductModel - - - - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml deleted file mode 100644 index 44c495ef9..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml +++ /dev/null @@ -1,38 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ - if (Model.AvailableProductAttributes.Count > 0) - { - - - - -
- -
-
-
- - -
- -
-
-
- -
-
- } - else - { -
- @Loc["Admin.Catalog.Products.ProductAttributes.NoAttributesAvailable"] -
- } -} -else -{ -
- @Loc["Admin.Catalog.Products.ProductAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml deleted file mode 100644 index 0efbe81e5..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ /dev/null @@ -1,147 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Directory -@model ProductModel -@inject ICurrencyService currencyService -@{ - var currencies = await currencyService.GetAllCurrencies(); - var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); - currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); -} -@if (!string.IsNullOrEmpty(Model.Id) && currencies.Count > 0) -{ - if (currencies.Count > 0) - { -
-
-
-
-
- - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml deleted file mode 100644 index e5d748de0..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Recommended"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Recommended.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml deleted file mode 100644 index 7b402d6bf..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Admin.Catalog.Products.RelatedProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.RelatedProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml deleted file mode 100644 index 5869caccc..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ /dev/null @@ -1,86 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings - -
- -
-
-
- -
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml deleted file mode 100644 index 79f2ffd1e..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Admin.Catalog.Products.SimilarProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SimilarProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml deleted file mode 100644 index 509401a68..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ /dev/null @@ -1,159 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 51140f456..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,153 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model ProductModel -@inject IPermissionService permissionService -@{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - - - -
-
-
- - -
- -
-
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - -
- -
-
-
- - -
- -
-
-
- if (canManageDocuments) - { - - -
- -
-
-
- } - - - -
- -
-
-
- } - -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml deleted file mode 100644 index 4bca2da0f..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductAttributeValueModel - -
- - - - - - - - -@{ - Func - template = @
-
- -
- - -
-
- -
; -} - - -
- - - -
-
- -
- - -
-
-
-
- -
- @if (Model.DisplayColorSquaresRgb) - { -
- -
- -
- - -
-
- } - @if (Model.DisplayImageSquaresPicture) - { -
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.ProductId; - } - -
- - -
-
- } -
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
    -
  • -

    - checked="checked" - }> -

    -

    - -

    -
  • - @foreach (var picture in Model.ProductPictureModels) - { -
  • -

    - checked="checked" - }> -

    -

    - -

    -
  • - } -
- -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml deleted file mode 100644 index 628574efb..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ /dev/null @@ -1,69 +0,0 @@ -@model ProductModel.TierPriceModel - -
- - - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 000000000..3eb6382d4 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 000000000..0de5077bc --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml new file mode 100644 index 000000000..c689c8caf --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml new file mode 100644 index 000000000..6a4f1a801 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml new file mode 100644 index 000000000..df341bc29 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 000000000..505ed8ce8 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml new file mode 100644 index 000000000..ab481085f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml new file mode 100644 index 000000000..f229db5a0 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 000000000..4ba491b7e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml new file mode 100644 index 000000000..55035187a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml new file mode 100644 index 000000000..45d653435 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml new file mode 100644 index 000000000..251a09296 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml new file mode 100644 index 000000000..27dc7ffae --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml new file mode 100644 index 000000000..a13716f2c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml new file mode 100644 index 000000000..794ead665 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml new file mode 100644 index 000000000..3cfbffb41 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml new file mode 100644 index 000000000..63c55836a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml new file mode 100644 index 000000000..3ed01211c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml new file mode 100644 index 000000000..80b46d669 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..42997cf59 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..230607bc6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..aa5390666 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..569744a25 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..cefcddbd8 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml new file mode 100644 index 000000000..557199023 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml new file mode 100644 index 000000000..f5f524ced --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml new file mode 100644 index 000000000..b0ce1c846 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml new file mode 100644 index 000000000..7133c46ec --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml new file mode 100644 index 000000000..1e4841da3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml new file mode 100644 index 000000000..333668bfc --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml new file mode 100644 index 000000000..9f48290fc --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml new file mode 100644 index 000000000..926e78632 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 000000000..a01328dfd --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 000000000..27cdbacf2 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 000000000..457da7c51 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 000000000..9423f693e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..7932bdf55 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..1f49d9a74 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 000000000..91c03fd27 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 000000000..b7958f1be --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..17b179aa1 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 000000000..10fc2245f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 000000000..a5544db33 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 000000000..559aa4d73 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml deleted file mode 100644 index f9012f5a4..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml +++ /dev/null @@ -1,205 +0,0 @@ -@model ProductAttributeConditionModel -@{ - Layout = ""; - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"]; -} -
- - -
- - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"] -
-
-
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] -
-
-
-
-
- -
- - -
-
-
- @if (Model.ProductAttributes.Count > 0) - { - var attributesList = new List(); - foreach (var attribute in Model.ProductAttributes) - { - attributesList.Add(new SelectListItem { Text = attribute.Name, Value = attribute.Id }); - } - -
-
-
- -
- - -
-
-
- -
- @foreach (var attribute in Model.ProductAttributes) - { - var controlId = $"attributes[{attribute.Id}]"; -
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.Checkboxes: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - break; - } -
- } -
-
-
-
- } - else - { -
No attribute exists that could be used as condition
- } -
-
-
-
- -
-
-
- -
-
-
-
-
-
- - - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml deleted file mode 100644 index 24a392b4f..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ /dev/null @@ -1,59 +0,0 @@ -@model ProductModel.ProductAttributeValueModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml deleted file mode 100644 index b9a3c24ff..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml +++ /dev/null @@ -1,201 +0,0 @@ -@model ProductModel.AddProductSpecificationAttributeModel -@{ - Layout = ""; -} -
- -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.AddNew"] - } - else - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml deleted file mode 100644 index 33a330258..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.AddNew"]; -} - -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml deleted file mode 100644 index 6cfd03c6f..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.Edit"]; -} - -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.TierPrices.Edit"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index ec406619b..aacd760d9 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -1,2479 +1,38 @@ -using Grand.Business.Core.Dto; -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; -using Grand.Domain.Permissions; -using Grand.SharedKernel.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseAdminController -{ - #region Constructors - - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion - - #region Methods - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - var (productModels, totalCount) = - await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) - { - return RedirectToAction("Edit", "Product", new { id = product.Id }); - } - - //not found - Warning(_translationService.GetResource("Admin.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel(); - await _productViewModelService.PrepareProductModel(model, null, true, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Admin.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, null, false, true); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - var model = product.ToModel(_dateTimeService); - //model.Ticks = product.UpdatedOnUtc.Ticks; - - await _productViewModelService.PrepareProductModel(model, product, false, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Admin.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - product = await _productViewModelService.UpdateProductModel(product, model); - Success(_translationService.GetResource("Admin.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, product, false, true); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Admin.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteSelected(ICollection selectedIds) - { - if (selectedIds != null) await _productViewModelService.DeleteSelected(selectedIds.ToList()); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - var newProduct = await copyProductService.CopyProduct(originalProduct, - copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var ids = new List(); - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - foreach (var str1 in rangeArray) ids.Add(str1); - - var products = await _productService.GetProductsByIds(ids.ToArray(), true); - for (var i = 0; i <= products.Count - 1; i++) - { - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(); - ViewBag.productIdsInput = productIdsInput; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRelatedProductModel(); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareSimilarProductModel(); - return View(model); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareBundleProductModel(); - return View(model); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareCrossSellProductModel(); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRecommendedProductModel(); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertAssociatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new - { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new - { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new - { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = file.FileName; - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productrSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes()) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - var model = new OrderListModel { - ProductId = productId - }; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", "", productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelAll(ProductListModel model, - [FromServices] IExportManager exportManager) - { - var products = await _productViewModelService.PrepareProducts(model); - try - { - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelSelected(string selectedIds, - [FromServices] IExportManager exportManager) - { - var products = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - products.AddRange(await _productService.GetProductsByIds(ids, true)); - } - - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - - [PermissionAuthorizeAction(PermissionActionName.Import)] - [HttpPost] - public async Task ImportExcel(IFormFile importexcelfile, - [FromServices] IImportManager importManager) - { - try - { - if (importexcelfile is { Length: > 0 }) - { - await importManager.Import(importexcelfile.OpenReadStream()); - } - else - { - Error(_translationService.GetResource("Admin.Common.UploadFile")); - return RedirectToAction("List"); - } - - Success(_translationService.GetResource("Admin.Catalog.Products.Imported")); - return RedirectToAction("List"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - if (products != null) await _productViewModelService.UpdateBulkEdit(products.ToList()); - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - if (products != null) await _productViewModelService.DeleteBulkEdit(products.ToList()); - return new JsonResult(""); - } - - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string Id) - { - var product = await _productService.GetProductById(productId); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, Id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - var warnings = await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, command.Page - 1, - command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var reservations = await _productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new - { - errors = _translationService.GetResource("Admin.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = 0; - switch ((IntervalUnit)model.IntervalUnit) - { - case IntervalUnit.Minute: - minutesToAdd = model.Interval; - break; - case IntervalUnit.Hour: - minutesToAdd = model.Interval * 60; - break; - case IntervalUnit.Day: - minutesToAdd = model.Interval * 60 * 24; - break; - } - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Admin's DI wiring plus the attributes +// that used to arrive transitively via BaseAdminController - BaseProductController can't inherit any +// single host's base controller (it's shared across Admin/Store/Vendor, each with a different +// [Area]/[Authorize*] pair), so each subclass restates its own host's attribute set explicitly. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs index a3093b18d..23cdc2c44 100644 --- a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs @@ -2,8 +2,6 @@ using elFinder.Net.Drivers.FileSystem.Extensions; using Grand.Infrastructure; using Grand.Web.Admin.Infrastructure; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Services; using Grand.Web.Common.View; namespace Grand.Web.Admin.Startup; @@ -13,6 +11,9 @@ public class StartupApplication : IStartupApplication public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own + // StartupApplication via RoutedProductDataScope - see its doc comment. Registering it here + // too would race with Store's/Vendor's registrations under the combined Grand.Web host. } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs new file mode 100644 index 000000000..c55964107 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -0,0 +1,3409 @@ +using Grand.Business.Core.Dto; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.StaticFiles; + +namespace Grand.Web.AdminShared.Controllers; + +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6, corrected after review — see Task 6's +// ledger entry). Inlined in full here (not just referenced) since planning artifacts under .superpowers/ +// are untracked and do not survive in the repo once this branch merges. +// +// Templated via {scope.ResourceKeyPrefix} (Admin. and Vendor. both exist) — 23: +// Common.All, Customers.Guest, Configuration.Tax.Settings.TaxCategories.None, +// Catalog.Products.Added, Catalog.Products.Updated, Catalog.Products.Deleted, +// Catalog.Products.Fields.ChangedWarning, Catalog.Products.Fields.DeliveryDate.None, +// Catalog.Products.Fields.Warehouse.None, Catalog.Products.Bids.CantDeleteWithOrder, +// Catalog.Products.List.SkuNotFound, Catalog.Products.List.SearchPublished.All, +// Catalog.Products.List.SearchPublished.PublishedOnly, Catalog.Products.List.SearchPublished.UnpublishedOnly, +// Catalog.Products.List.SearchPublished.MarkAsNew, Catalog.ProductReservations.CantDeleteWithOrder, +// Catalog.Products.Calendar.CannotChangeInterval, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue, +// Catalog.Products.Permissions (CORRECTED 2026-08-16, Task 8 row "Product categories": this row's +// original pass kept it as an "Admin-only literal" below, trusting Task 6's audit - but that audit +// only scanned the 5 files under migration [2 ProductControllers + 2 ProductViewModelServices], never +// validators. "Vendor.Catalog.Products.Permissions" genuinely exists in +// src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml and is consumed by +// Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs and BundleProductModelValidator.cs. +// Lesson for later rows: "no call site found in the files under migration" is NOT the same claim as +// "no resource key exists for Vendor" - check the XML resource files too before treating a key as +// host-specific). +// +// Admin-only literal (no Vendor equivalent call site; keep as literal "Admin.") — 5: +// Catalog.Products.List.SearchPublished.ShowOnHomePage, +// Catalog.Products.Imported, Catalog.Products.TierPrices.Fields.CustomerGroup.All, +// Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. +// +// Host-specific, not templated — 0: none found; every "Vendor." call site has a matching +// "Admin." one, so nothing needs a scope.ResourceKeyPrefix == "Vendor" guard instead of templating. +// +// Store makes no separate resource lookups at all - every Store call site uses the literal "Admin.*" key +// directly (Store has no distinct resource set), consistent with StoreAdminDataScope.ResourceKeyPrefix +// returning "Admin". + +// [AutoValidateAntiforgeryToken] is also restated on each of the three concrete host subclasses +// (Admin/Store/Vendor ProductController) - ASP.NET Core resolves filters from the full type hierarchy +// of the concrete controller at runtime, so those subclass-level attributes already protect every +// [HttpPost] action defined here. It's added here too so CodeQL's static analysis (which flagged this +// file directly - it doesn't follow the attribute from a derived class in a different project back +// onto the base class where the actions are textually defined) has something to see in the same file +// as the actions, and so a future host subclass that forgets to restate the attribute doesn't silently +// lose CSRF protection - BaseProductController itself is abstract and never directly routable, so this +// changes no runtime behavior today. +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass; no-op everywhere else. + protected virtual void EditWarningCheck(Product product) { } + + // Exposed for host subclasses (ARCH-001 Phase 1 Task 11): primary-constructor parameters are not + // visible to derived classes by name in C#, but Store's EditWarningCheck override and any other + // host-specific override needs to reference these. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + + #region Product list / create / edit / delete + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = await productViewModelService.PrepareProductListModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, ProductListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (productModels, totalCount) = + await productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); + return Json(new DataSourceResult { Data = productModels.ToList(), Total = totalCount }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task GoToSku(ProductListModel model) + { + var product = await productService.GetProductBySku(model.GoDirectlyToSku); + if (product != null) + { + // TODO(ARCH-001-followup): Store's pre-refactor code had a security-relevant bug here - + // on access denial it fell into `if (!CanAccessProduct(product)) return RedirectToAction("Edit", ...)`, + // i.e. it redirected an unauthorized caller straight to the Edit screen of a product outside + // their store. (On access *granted* the old code had a separate, non-security bug: it fell + // through to the "not found" Warning + redirect-to-List below instead of going to Edit.) + // The merged behavior below deliberately tightens this: deny -> List (matching Vendor's + // stricter pattern), grant -> Edit. This is an intentional behavior change, not a faithful + // port - call it out in the PR description. + if (!await scope.HasAccess(product)) + return RedirectToAction("List", "Product"); + return RedirectToAction("Edit", "Product", new { id = product.Id }); + } + + Warning(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SkuNotFound")); + return RedirectToAction("List", "Product"); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new ProductModel { StoreId = scope.DefaultStoreId }; + await productViewModelService.PrepareProductModel(model, null, true, true); + await AddLocales(languageService, model.Locales); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(ProductModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + var product = await productViewModelService.InsertProductModel(model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, null, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + + EditWarningCheck(product); + // CanView, not HasAccess: viewing a shared/global product is allowed on Store (with a warning + // from EditWarningCheck above); only mutating one is restricted to the exclusive single-store + // owner. See IAdminDataScope.CanView's doc comment and StoreAdminDataScope.CanView. + if (!await scope.CanView(product)) return RedirectToAction("List"); + + var model = product.ToModel(dateTimeService); + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, false); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = product.GetTranslation(x => x.Name, languageId, false); + locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); + locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); + locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = product.GetSeName(languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(ProductModel model, bool continueEditing) + { + var product = await productService.GetProductById(model.Id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id = product.Id }); + + if (model.Ticks != product.Ticks) + { + Error(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Fields.ChangedWarning")); + return RedirectToAction("Edit", new { id = product.Id }); + } + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + product = await productViewModelService.UpdateProductModel(product, model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = product.Id }); + } + + return RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id }); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProduct(product); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task DeleteSelected(ICollection selectedIds) + { + if (selectedIds == null || selectedIds.Count == 0) return Json(new { Result = true }); + + // This is a mutation (bulk delete), so it uses the strict HasAccess, matching Edit(POST)/Delete + // above — not a no-op pass-through to the service. Without this filter, Store gains an + // unscoped bulk-delete endpoint (any store staff could delete any product id in the system, + // bypassing AccessToEntityByStore entirely) purely because MVC routes actions regardless of + // whether a host's views ever link to them. + var products = await productService.GetProductsByIds(selectedIds.ToArray(), true); + var allowedIds = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + allowedIds.Add(product.Id); + + if (allowedIds.Count > 0) await productViewModelService.DeleteSelected(allowedIds); + return Json(new { Result = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + public async Task CopyProduct(ProductModel model, + [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) + { + var copyModel = model.CopyProductModel; + try + { + var originalProduct = await productService.GetProductById(copyModel.Id, true); + // CanView, not HasAccess: Store's original CopyProduct denies only when LimitedToStores is + // true AND the staff member's store isn't among them — the same looser rule as Edit(GET) + // above, not the strict mutation rule. See IAdminDataScope.CanView. + if (!await scope.CanView(originalProduct)) return RedirectToAction("List"); + + if (scope.DefaultStoreId is not null) + { + originalProduct.LimitedToStores = true; + originalProduct.Stores.Clear(); + originalProduct.Stores.Add(scope.DefaultStoreId); + } + + var newProduct = await copyProductService.CopyProduct(originalProduct, copyModel.Name, copyModel.Published); + if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); + + Success("The product has been copied successfully"); + return RedirectToAction("Edit", new { id = newProduct.Id }); + } + catch (Exception exc) + { + Error(exc.Message); + return RedirectToAction("Edit", new { id = copyModel.Id }); + } + } + + private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) + { + foreach (var productPicture in originalProduct.ProductPictures) + { + var picture = await pictureService.GetPictureById(productPicture.PictureId); + var pictureCopy = await pictureService.InsertPicture( + await pictureService.LoadPictureBinary(picture), + picture.MimeType, + pictureService.GetPictureSeName(newProduct.Name), + picture.AltAttribute, + picture.TitleAttribute, + false, + Reference.Product, + newProduct.Id); + + await productService.InsertProductPicture(new ProductPicture { + PictureId = pictureCopy.Id, + DisplayOrder = productPicture.DisplayOrder, + IsDefault = productPicture.IsDefault + }, newProduct.Id); + } + } + + #endregion + + #region Required products + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task LoadProductFriendlyNames(string productIds) + { + var result = ""; + + if (!string.IsNullOrWhiteSpace(productIds)) + { + var ids = productIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .ToList(); + + var products = await productService.GetProductsByIds(ids.ToArray(), true); + for (var i = 0; i <= products.Count - 1; i++) + { + // Filters the friendly-name list, not a hard deny of the whole action: matches Store's + // CanAccessProduct loop and Vendor's HasAccessToProduct loop, both of which skip + // inaccessible products silently rather than erroring the whole request. Both are the + // strict rule (AccessToEntityByStore / VendorId equality), so HasAccess (not CanView) is + // correct here - this is filtering a display list, not opening/copying a single entity. + if (!await scope.HasAccess(products[i])) continue; + + result += products[i].Name; + if (i != products.Count - 1) + result += ", "; + } + } + + return Json(new { Text = result }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RequiredProductAddPopup(string productIdsInput) + { + // PrepareAddRequiredProductModel() now reads scope internally (Task 9) - the injected + // IAdminDataScope already encodes the per-host default exactly: null for Admin + // (global) and Vendor (not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareAddRequiredProductModel(); + // Unused by any of the three views (all three read productIdsInput straight off the query string + // via Context.Request.Query, not ViewBag), but Admin and Vendor both set it and Store silently + // drops its own parameter - kept here for parity; it is inert either way. + ViewBag.productIdsInput = productIdsInput; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RequiredProductAddPopupList(DataSourceRequest command, + ProductModel.AddRequiredProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + #endregion + + #region Product categories + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCategoryList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same + // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's + // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting + // any vendor mutate another vendor's product-category mappings by id. + if (!await scope.HasAccess(product)) + // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header + // comment above) only covered the files under migration (the 2 ProductControllers + 2 + // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's + // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the + // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by + // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was + // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); + var gridModel = new DataSourceResult { + Data = productCategoriesModel, + Total = productCategoriesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product collections + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCollectionList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Product categories" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying + // it uniformly also closes the same kind of gap found in "Product categories": Store's and + // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all + // - only List checked - letting any store manager or vendor mutate another party's + // product-collection mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); + var gridModel = new DataSourceResult { + Data = productCollectionsModel, + Total = productCollectionsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Related products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RelatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Product categories"/"Product collections" - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: + // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at + // all - only List checked - letting any vendor mutate another vendor's related-product mappings + // by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); + var relatedProductsModel = new List(); + foreach (var x in relatedProducts) + relatedProductsModel.Add(new ProductModel.RelatedProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = relatedProductsModel, + Total = relatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RelatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRelatedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRelatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRelatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); + return Content(""); + } + + return await InvalidRelatedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRelatedProductModel(); + return View(model); + } + + #endregion + + #region Similar products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task SimilarProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products" above - mirrors Store's + // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's + // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - + // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's + // similar-product mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); + var similarProductsModel = new List(); + foreach (var x in similarProducts) + similarProductsModel.Add(new ProductModel.SimilarProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = similarProductsModel, + Total = similarProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SimilarProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareSimilarProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareSimilarProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopupList(DataSourceRequest command, + ProductModel.AddSimilarProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); + return Content(""); + } + + return await InvalidSimilarProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareSimilarProductModel(); + return View(model); + } + + #endregion + + #region Bundle products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task BundleProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products"/"Similar products" above - + // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the + // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ + // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via + // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); + var bundleProductsModel = new List(); + foreach (var x in bundleProducts) + bundleProductsModel.Add(new ProductModel.BundleProductModel { + Id = x.Id, + ProductBundleId = productId, + ProductId = x.ProductId, + ProductName = (await productService.GetProductById(x.ProductId))?.Name, + DisplayOrder = x.DisplayOrder, + Quantity = x.Quantity + }); + var gridModel = new DataSourceResult { + Data = bundleProductsModel, + Total = bundleProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductUpdate(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductDelete(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task BundleProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareBundleProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareBundleProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopupList(DataSourceRequest command, + ProductModel.AddBundleProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings + // onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); + return Content(""); + } + + return await InvalidBundleProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareBundleProductModel(); + return View(model); + } + + #endregion + + #region Cross-sell products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CrossSellProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products"/"Bundle products" above - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly + // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for + // Admin's scope). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProducts = product.CrossSellProduct; + var crossSellProductsModel = new List(); + foreach (var x in crossSellProducts) + crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = crossSellProductsModel, + Total = crossSellProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's + // cross-sell-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(crossSellProduct)) + throw new ArgumentException("No cross-sell product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CrossSellProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareCrossSellProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareCrossSellProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopupList(DataSourceRequest command, + ProductModel.AddCrossSellProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); + return Content(""); + } + + return await InvalidCrossSellProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareCrossSellProductModel(); + return View(model); + } + + #endregion + + #region Recommended products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RecommendedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Cross-sell products" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's + // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes + // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). + // Vendor's original signature also dropped the DataSourceRequest command parameter entirely + // (unused by the body on any host either way) - kept here for parity with Admin/Store. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProductsModel = new List(); + foreach (var x in product.RecommendedProduct) + recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = recommendedProductsModel, + Total = recommendedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's + // recommended-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(recommendedProduct)) + throw new ArgumentException("No recommended product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RecommendedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRecommendedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRecommendedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRecommendedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); + return Content(""); + } + + return await InvalidRecommendedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRecommendedProductModel(); + return View(model); + } + + #endregion + + #region Associated products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task AssociatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into + // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full + // associated-product set, the ones they themselves own. That vendor filter is unconditional in + // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies + // when showHidden is false - moot here since all three hosts pass showHidden: true). + var associatedProducts = await productService.GetAssociatedProducts(productId, + vendorId: AssociatedProductVendorId, showHidden: true); + var associatedProductsModel = associatedProducts + .Select(x => new ProductModel.AssociatedProductModel { + Id = x.Id, + ProductId = productId, + ProductName = x.Name, + DisplayOrder = x.DisplayOrder + }) + .ToList(); + + var gridModel = new DataSourceResult { + Data = associatedProductsModel, + Total = associatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) + { + var associatedProduct = await productService.GetProductById(model.Id); + if (associatedProduct == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and + // Store's CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(associatedProduct)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + associatedProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateAssociatedProduct(associatedProduct); + + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) + { + var product = await productService.GetProductById(model.Id); + if (product == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's + // CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteAssociatedProduct(product); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AssociatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (all open this popup unconditionally + // once the Edit permission is satisfied) - only the mutating actions below tie access to a + // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). + var model = await productViewModelService.PrepareAssociatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddAssociatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) + { + var parentProduct = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. + // Vendor's controller had NO check on the parent product at all (see below for the selected-ids + // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected + // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor + // could attach their own products under another vendor's grouped product (cross-vendor storefront + // pollution / data-integrity issue, not a write to someone else's product record). Closed here the + // same way as every other AddPopup(POST) in this task. + if (!await scope.HasAccess(parentProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + // InsertAssociatedProductModel reparents each selected product - it writes + // ParentGroupedProductId directly onto that product's own record - unlike + // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping + // entry that references the selected product's id from the parent's own list; the selected + // product's record is never itself modified there. So here, unlike those simpler regions, + // every selected id must independently pass HasAccess too - the same per-id filtering Store's + // controller-level original already did, and which Vendor's original also had, but only at the + // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: + // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController + // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so + // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter + // entirely. Enforcing the per-id check here at the controller level is what preserves the + // invariant going forward. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (await scope.HasAccess(selected)) validIds.Add(id); + } + + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); + } + + return Content(""); + } + + // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact + // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's + // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it + // uses in those other regions, so no host-specific hook is needed for this action. + Error(ModelState); + model = await productViewModelService.PrepareAssociatedProductModel(); + return View(model); + } + + /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in + /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into + /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated + /// products that they themselves own. Admin/Store passed no vendorId (both show every associated + /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it + /// once hosts are subclassed onto BaseProductController (Task 11). + protected virtual string AssociatedProductVendorId => ""; + + #endregion + + #region Product pictures + + [HttpPost] + public async Task ProductPictureAdd( + IFormFileCollection files, + Reference reference, string objectId, + [FromServices] IPictureService pictureService, + [FromServices] MediaSettings mediaSettings) + { + if (!await permissionService.Authorize(PermissionSystemName.Pictures)) + return Json(new { + success = false, + message = "Access denied - picture permissions" + }); + + if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) + return Json(new { + success = false, + message = "Please save form before upload new pictures" + }); + + if (!files.Any()) + return Json(new { + success = false, + message = "No files uploaded" + }); + + var product = await productService.GetProductById(objectId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's inline + // WorkContext.HasAccessToProduct check gating this action on both hosts. Admin's original had no + // check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the same + // way as every other row in this task. Message text kept generic rather than reusing either host's + // wording ("Access denied - staff permissions" / "Access denied - vendor permissions") since this + // is a shared, host-neutral action now. + if (!await scope.HasAccess(product)) + return Json(new { + success = false, + message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") + }); + + // File-upload validation note (ARCH-001 Phase 1 Task 8 row "Product pictures", 2026-08-16): + // extension checking here already goes through FileExtensions.GetAllowedMediaFileTypes, which - + // per commit a153496a6's fix - falls back to a safe image-only allow-list when + // mediaSettings.AllowedFileTypes is empty, so the "empty config = any extension" bypass that + // commit fixed for attribute uploads does not apply here. However, unlike the attribute-upload + // paths that commit hardened (Contact/ShoppingCart/Product's ValidationFileMaximumSize check + // against file.Length before buffering), this action has NO file-size limit at all in any of the + // three original hosts - file.GetDownloadBits() buffers the full upload into memory unconditionally + // for every file that passes the extension check. This is pre-existing, identical behavior across + // all three hosts (not introduced by this consolidation), so it is ported as-is rather than + // "fixed" here per this row's instructions - flagging as a concern: the admin/vendor/store picture + // upload endpoints may be exposed to the same memory-DoS pattern a153496a6 fixed elsewhere, and + // would need an explicit size check (and a decision on what setting should carry the limit, since + // MediaSettings has no equivalent of ValidationFileMaximumSize) before that gap is closed. + var values = new List<(string pictureUrl, string pictureId)>(); + foreach (var file in files) + { + var fileName = Path.GetFileName(file.FileName); + var contentType = file.ContentType; + var fileExtension = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(contentType)) + _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); + + if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) + { + var fileBinary = file.GetDownloadBits(); + //insert picture + var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, + objectId: objectId); + var pictureUrl = await pictureService.GetPictureUrl(picture); + + values.Add((pictureUrl, picture.Id)); + //assign picture to the product + await productViewModelService.InsertProductPicture(product, picture, 0); + } + } + + return Json(new { success = values.Any(), data = values }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPictureList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. Vendor's original signature + // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo + // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for + // Vendor since an unused extra bound parameter changes nothing about the response. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); + var gridModel = new DataSourceResult { + Data = productPicturesModel, + Total = productPicturesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task ProductPicturePopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + return Content("Product not exist"); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); + if (pp == null) + return Content("Product picture not exist"); + + var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); + locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another + // vendor's product picture by posting its productId/model.Id - closed here the same way as the + // other rows in this task. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) + throw new ArgumentException("No product picture found with the specified id"); + + await productViewModelService.UpdateProductPicture(model); + + return Content(""); + } + + Error(ModelState); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product + // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) + // above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductPicture(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product specification attributes + + //ajax + [AcceptVerbs("GET")] + public async Task GetOptionsByAttributeId(string attributeId, + [FromServices] ISpecificationAttributeService specificationAttributeService) + { + if (string.IsNullOrEmpty(attributeId)) + return Json(""); + + var options = + (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) + .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); + var result = (from o in options + select new { id = o.Id, name = o.Name }).ToList(); + return Json(result); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductSpecAttrList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying + // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess + // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest + // command parameter entirely (unused by the body on any host either way) - kept here for parity + // with Admin/Store. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); + var gridModel = new DataSourceResult { + Data = productrSpecsModel, + Total = productrSpecsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + string productId, string id) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on + // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same + // way as ProductSpecAttrList above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new ProductModel.AddProductSpecificationAttributeModel { + //default specs values + ShowOnProductPage = true + }; + + if (!string.IsNullOrEmpty(id)) + { + var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); + if (specification != null) model = specification.ToModel(); + } + + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + ProductModel.AddProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrPopup(POST) had no check at all, letting any vendor add/edit specification + // attributes on another vendor's product by posting its id - closed here the same way as the + // GET popup above. UpdateProductSpecificationAttributeModel's unused `product` parameter was + // dropped in ARCH-001 Phase 1 Task 10 to match Vendor's original two-arg (psa, model) shape. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + await productViewModelService.InsertProductSpecificationAttributeModel(model, product); + else + await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); + + return new JsonResult(""); + } + + Error(ModelState); + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call + /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification + /// attribute regardless of store). + private async Task> PrepareAvailableAttributes( + ISpecificationAttributeService specificationAttributeService) + { + var availableSpecificationAttributes = new List(); + foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) + availableSpecificationAttributes.Add(new SelectListItem { + Text = sa.Name, + Value = sa.Id + }); + return availableSpecificationAttributes; + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's + // specification attribute mapping by posting its productId/model.Id - closed here the same + // way as ProductSpecAttrPopup(POST) above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + throw new ArgumentException("No specification attribute found with the specified id"); + + await productViewModelService.DeleteProductSpecificationAttribute(product, psa); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Purchased with order + + // Type note: this method covers Admin and Store only. Both already share + // Grand.Web.AdminShared's IOrderViewModelService and Models.Orders.OrderListModel (see the usings + // at the top of this file). Vendor defines its own, structurally different + // Grand.Web.Vendor.Interfaces.IOrderViewModelService and Grand.Web.Vendor.Models.Orders.OrderListModel + // (no StoreId property at all - Vendor's PrepareOrderModel scopes by + // _contextAccessor.WorkContext.CurrentVendor.Id internally, not via any model field), so it cannot + // bind to this signature. That vendor-id scoping lives entirely inside Vendor's own + // OrderViewModelService, outside anything IAdminDataScope expresses - flagging as a concern + // per the task brief rather than inventing a shared model/service pair. Not virtual: C#'s override + // rules require an exact parameter-type match, so a Vendor override using + // Grand.Web.Vendor.Interfaces.IOrderViewModelService/Models.Orders.OrderListModel could never compile + // as an override of this signature anyway. When Vendor is wired onto this base controller (Task 11), + // its subclass will declare its own PurchasedWithOrders action with `new` to shadow this one (a + // standard, valid ASP.NET Core MVC pattern for a derived controller needing an incompatible signature + // under the same action name), not override it. + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task PurchasedWithOrders(DataSourceRequest command, string productId, + [FromServices] IOrderViewModelService orderViewModelService) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Json(new DataSourceResult { + Data = null, + Total = 0 + }); + + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Admin's original had + // no check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the + // same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new OrderListModel { + ProductId = productId, + // DefaultStoreId is the staff member's store for Store (matches its original + // model.StoreId = StaffStoreId) and null for Admin (matches its original, which never set + // StoreId at all). + StoreId = scope.DefaultStoreId + }; + + var (orderModels, totalCount) = + await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = orderModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + #endregion + + #region Reviews + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task Reviews(DataSourceRequest command, string productId, + [FromServices] IProductReviewService productReviewService) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // DefaultStoreId is the staff member's store for Store (matches its original storeId argument, + // which filtered reviews to the staff member's store) and null - normalized to "" here, matching + // GetAllProductReviews's expected "no filter" value - for both Admin and Vendor (matches their + // originals, which both passed "" literally; Vendor scopes by VendorId via the HasAccess check + // above, not by store, since VendorProductDataScope.DefaultStoreId is null). + var storeId = scope.DefaultStoreId ?? ""; + + var productReviews = await productReviewService.GetAllProductReviews("", null, + null, null, "", storeId, productId); + + var items = new List(); + foreach (var item in productReviews.PagedForCommand(command)) + { + var m = new ProductReviewModel(); + await productViewModelService.PrepareProductReviewModel(m, item, false, true); + items.Add(m); + } + + var gridModel = new DataSourceResult { + Data = items, + Total = productReviews.Count + }; + + return Json(gridModel); + } + + #endregion + + #region Export / Import + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task ExportExcelAll(ProductListModel model, + [FromServices] IExportManager exportManager) + { + // vendorId is forced inside PrepareProducts itself (scope.DefaultVendorId ?? model.SearchVendorId), + // but storeId is not - every other caller of PrepareProductsModel/PrepareProducts in this file + // forces it here, at the call site, the same way ProductList does above. This one was missing it: + // a Store-scoped user could POST an arbitrary (or blank) SearchStoreId and export every store's + // products, not just their own. Found during a post-Phase-2 tenant-isolation audit. + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var products = await productViewModelService.PrepareProducts(model); + try + { + var bytes = await exportManager.Export(products); + return File(bytes, "text/xls", "products.xlsx"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task ExportExcelSelected(string selectedIds, + [FromServices] IExportManager exportManager) + { + var products = new List(); + if (selectedIds != null) + { + var ids = selectedIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x) + .ToArray(); + products.AddRange(await productService.GetProductsByIds(ids, true)); + } + + // Unlike ExportExcelAll, selectedIds is caller-supplied and not derived from a scoped search - + // Vendor's original explicitly re-checked HasAccessToProduct per id for exactly this reason + // (a vendor could otherwise pass another vendor's product id and export it). Admin's original had + // no check at all (GlobalAdminDataScope.HasAccess is a no-op there), so applying the filter + // unconditionally closes that gap the same way as every other row in this task, without changing + // Admin's or Store's observable behavior. + var scoped = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + scoped.Add(product); + + var bytes = await exportManager.Export(scoped); + return File(bytes, "text/xls", "products.xlsx"); + } + + // Not virtual: Vendor's original ProductController has no ImportExcel action at all, and Vendor is + // never granted the "Products" permission's Import action (grep across src/Web/Grand.Web.Vendor found + // no PermissionActionName.Import usage anywhere) - vendors are deliberately not allowed to bulk-import + // products. [PermissionAuthorizeAction(PermissionActionName.Import)] below already 403s for any host + // whose role has no Import grant for Products, so this is safe to expose unconditionally on the base + // class; Vendor's (future) subclass simply never routes a view to it, same as any other + // permission-gated action already in this file. + // + // Concern (flagged, not fixed - out of scope for this row): ImportExcel only checks + // `importexcelfile.Length > 0` before handing the raw stream to IImportManager.Import. + // There is no file-extension allowlist and no upper bound on Length before the stream is read into + // memory by the importer. Commit a153496a6 hardened exactly this shape of gap (memory DoS + extension + // bypass) for attribute file uploads; this action has the same shape and was not touched by that fix. + // This is pre-existing behavior being ported verbatim, not something introduced by this migration - + // worth a follow-up ticket, not a silent fix here. + [PermissionAuthorizeAction(PermissionActionName.Import)] + [HttpPost] + public async Task ImportExcel(IFormFile importexcelfile, + [FromServices] IImportManager importManager) + { + try + { + if (importexcelfile is { Length: > 0 }) + { + await importManager.Import(importexcelfile.OpenReadStream()); + } + else + { + Error(translationService.GetResource("Admin.Common.UploadFile")); + return RedirectToAction("List"); + } + + Success(translationService.GetResource("Admin.Catalog.Products.Imported")); + return RedirectToAction("List"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + #endregion + + #region Bulk editing + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task BulkEdit() + { + // PrepareBulkEditListModel() now reads scope internally (Task 9): null for Admin/Vendor + // (not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareBulkEditListModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + // BLOCKING PREREQUISITE for Task 11 (see plan's Task 10 PrepareBulkEditProductModel row and + // Task 11's blocking-prerequisite note, added in commit a8def835a after this gap was flagged and + // verified during review): Vendor's original bulk-edit grid was vendor-scoped - + // PrepareBulkEditProductModel passed vendorId: CurrentVendor.Id into productService.SearchProducts, + // so the grid only ever listed the vendor's own products. AdminShared's version isn't - + // IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter (unlike + // PrepareProductModel(AddProductModel), which supports SearchVendorId - see + // AssociatedProductVendorId above) - and BulkEditListModel carries no SearchVendorId field either. + // Closing this requires a ProductViewModelService/BulkEditListModel change, which this task's + // per-row scope (BaseProductController.cs + tests only) does not permit. Not a security gap by + // itself (a wider listing, not a mutation) - the mutate endpoints below (BulkEditUpdate/ + // BulkEditDelete) are scope-checked per item regardless and never leak another party's product - + // but Task 11 must NOT subclass Vendor onto this base controller until Task 10 adds vendor-scoped + // filtering here, or a vendor would see every vendor's products in this grid. + var (bulkEditProductModels, totalCount) = + await productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bulkEditProductModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BulkEditUpdate(IEnumerable products) + { + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.UpdateBulkEdit(validProducts); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task BulkEditDelete(IEnumerable products) + { + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.DeleteBulkEdit(validProducts); + + return new JsonResult(""); + } + + /// + /// Filters a caller-supplied bulk-edit product list (BulkEditUpdate/BulkEditDelete) down to the ones + /// the current user may mutate. Same shape as the DeleteSelected gap above: this is a caller-supplied + /// list of ids being mutated in one request, and each id must be scope-checked individually before + /// mutation, not just implicitly trusted because it appeared in a grid the user was shown. + /// + /// Admin's original had NO check at all here - both BulkEditUpdate and BulkEditDelete accepted a + /// client-supplied list of ids and mutated/deleted every one of them unconditionally, a real unscoped + /// bulk-mutate/bulk-delete IDOR (any authenticated admin user hitting these actions directly - not + /// through the grid - could update or delete any product in the system by id). + /// + /// Store's original had this exact check (FilterValidProductsForStore, via CanAccessProduct / + /// AccessToEntityByStore) - HasAccess (strict), matching CanAccessProduct's strict rule, not CanView. + /// + /// Vendor's original enforced the equivalent strict check (HasAccessToProduct / VendorId equality) + /// inside its own separate service (Grand.Web.Vendor's ProductViewModelService.UpdateBulkEdit/ + /// DeleteBulkEdit), not in the controller - functionally the same per-item gate, just placed one layer + /// down. Routing it through scope.HasAccess here reproduces that gate at the controller layer, where + /// the shared IProductViewModelService.UpdateBulkEdit/DeleteBulkEdit used by this class does not + /// filter internally (verified: both loop and mutate/delete every product they're given, no ownership + /// check). + /// + /// No-op for Admin (GlobalAdminDataScope.HasAccess is always true, so validProducts == products). + /// Silently drops missing/inaccessible ids rather than throwing (matches Admin's/Vendor's + /// null-then-skip behavior, not Store's original throw-on-missing-id - the majority behavior, and a + /// single bad id in a bulk request shouldn't fail the whole batch). + /// + private async Task> FilterBulkEditProductsByAccess( + IEnumerable products) + { + if (products == null) return []; + + var byId = products + .Where(x => !string.IsNullOrEmpty(x.Id)) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.First()); + if (byId.Count == 0) return []; + + var loadedProducts = await productService.GetProductsByIds(byId.Keys.ToArray(), true); + var validProducts = new List(); + foreach (var product in loadedProducts) + if (await scope.HasAccess(product)) + validProducts.Add(byId[product.Id]); + + return validProducts; + } + + #endregion + + #region Product currency price + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) check + // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's + // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check + // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had + // no check at all) - both let another party's product prices be updated/deleted by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var items = new List(); + foreach (var item in product.ProductPrices) + items.Add(new ProductModel.ProductPriceModel { + Id = item.Id, + CurrencyCode = item.CurrencyCode, + Price = item.Price, + ProductId = product.Id + }); + + var gridModel = new DataSourceResult { + Data = items, + Total = items.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("Currency code exists"); + + if (ModelState.IsValid) + try + { + await productService.InsertProductPrice(new ProductPrice { + ProductId = product.Id, + CurrencyCode = model.CurrencyCode, + Price = model.Price + }); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("You can't use this currency code"); + + if (ModelState.IsValid) + try + { + productPrice!.CurrencyCode = model.CurrencyCode; + productPrice.Price = model.Price; + productPrice.ProductId = model.ProductId; + + await productService.UpdateProductPrice(productPrice); + + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (ModelState.IsValid) + { + productPrice!.ProductId = model.ProductId; + await productService.DeleteProductPrice(productPrice); + + return new JsonResult(""); + } + + // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. + // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since + // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - + // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot + // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Tier prices + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task TierPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the + // same class of gap as "Product currency price": Vendor's original checked ownership only on List + // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier + // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess + // uniformly on every mutating action below closes that. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId + // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. + var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); + var gridModel = new DataSourceResult { + Data = tierPricesModel, + Total = tierPricesModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceCreatePopup(string productId) + { + var model = new ProductModel.TierPriceModel { + ProductId = productId + }; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Vendor's original never even loaded the product here - it inserted straight off + // model.ProductId with no ownership check at all. See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = model.ToEntity(dateTimeService); + await productService.InsertTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceEditPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Store's original never checked ownership on this GET action (only on the + // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, + // via this popup) the tier-price edit view for a product outside their store. Vendor's original did + // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. + if (!await scope.HasAccess(product)) + return Content("This is not your product"); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice == null) + return Content("Empty tier price"); + + var model = tierPrice.ToModel(dateTimeService); + model.ProductId = productId; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(productId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on this POST at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + return Content("Empty tier price"); + + tierPrice = model.ToEntity(tierPrice, dateTimeService); + await productService.UpdateTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //stores + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on Delete at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + throw new ArgumentException("No tier price found with the specified id"); + + await productService.DeleteTierPrice(tierPrice, product.Id); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product attributes + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally + // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither + // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at + // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on + // any product (not just their own/in-scope one) by posting a known productId. Vendor's + // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); + var gridModel = new DataSourceResult { + Data = attributesModel, + Total = attributesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(productAttributeMappingId)) + { + var model = await productViewModelService.PrepareProductAttributeMappingModel(product); + return View(model); + } + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, + productAttributeMapping); + return View(editModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the + // List-action comment. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(model.Id)) + await productViewModelService.InsertProductAttributeMappingModel(model); + else + await productViewModelService.UpdateProductAttributeMappingModel(model); + + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValidationRulesPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though + // this action returns a View (not a grid), which would have rendered raw JSON as page content on + // denial - using Content(...) instead, consistent with the other popup GET action in this region. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValidationRulesPopup( + ProductModel.ProductAttributeMappingModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); + if (productAttributeMapping == null) + throw new ArgumentException("No attribute value found with the specified id"); + + // HasAccess here too: none of the three original hosts checked ownership on this POST at all - + // a store/vendor user could update an attribute mapping's validation rules (min/max length, + // allowed file extensions, default value) on any product by posting a known productId/model.Id. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + #endregion + + #region Product attributes. Condition + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original + // had no ownership check on either action of this region. See the "Product attributes" region above + // for the same pattern. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + //No attribute value found with the specified id + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeConditionModel(product, + productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and + // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. + // That check was not inert: Vendor's ProductAttributeConditionModel implements the + // IProductValidVendor marker interface, which the global ValidationFilter resolves to + // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - + // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess + // (product) call below now performs the equivalent check directly against the loaded entity, + // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a + // request field. + // + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any + // product by posting a known productId/productAttributeMappingId. Admin's original had no check on + // either action. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); + return Content(""); + } + + #endregion + + #region Product attribute values + + //list + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task EditAttributeValues(string productAttributeMappingId, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's + // original had no ownership check on this action at all. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var productAttribute = + await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); + var model = new ProductModel.ProductAttributeValueListModel { + ProductName = product.Name, + ProductId = product.Id, + ProductAttributeName = productAttribute.Name, + ProductAttributeMappingId = productAttributeMappingId + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, + DataSourceRequest command) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var values = + await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); + var gridModel = new DataSourceResult { + Data = values, + Total = values.Count + }; + return Json(gridModel); + } + + //create + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, + string productId) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View (not a grid), which would have rendered raw JSON as page + // content on denial - using Content(...) instead, consistent with the sibling GET popup actions + // in this region and elsewhere in this file. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var model = + await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); + //locales + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no + // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at + // risk here despite having neither an explicit check nor a validator. Store's shared + // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever + // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this + // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the + // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a + // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a + // no-op - but ValidationFilter never short-circuits a non-JSON POST (see + // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's + // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the + // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently + // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now + // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning + // on a marker-interface side effect that only one host had. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + //No product attribute found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.InsertProductAttributeValueModel(model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueEditPopup(string id, string productId, + string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (pa == null) + return RedirectToAction("List", "Product"); + + var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = pav.GetTranslation(x => x.Name, languageId, false); + }); + //pictures + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueEditPopup(string productId, + ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - + // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), + // Vendor's original for THIS action already had an explicit inline check + // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), + // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check + // on either action of this pair. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) + ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValueModel(pav, model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //delete + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueDelete(string id, string pam, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here: this action takes only simple string parameters (no complex-typed POST + // body), so none of the model-level validators discussed above ever run for it. Admin's original + // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess + // now replaces uniformly. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues + .FirstOrDefault(x => x.Id == id); + if (pav == null) + throw new ArgumentException("No product attribute value found with the specified id"); + + if (ModelState.IsValid) + { + await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + public async Task AssociateProductToAttributeValuePopup() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): + // StaffStoreId for Store; null for Admin/Vendor (no store concept). + var model = + await productViewModelService.PrepareAssociateProductToAttributeValueModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopup( + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); + if (associatedProduct == null) + return Content("Cannot load a product"); + + // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's + // originals checked ownership of the *associated* product (the one about to be referenced as an + // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so + // the associated product is the only entity available to check. + if (!await scope.HasAccess(associatedProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + return Content(""); + } + + #endregion + + #region Product attribute combinations + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's + // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key + // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper + // that returned a plain hardcoded string ("This is not your product" / "Product not exists") + // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource + // key normalizes all three to the convention used throughout this file. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); + var gridModel = new DataSourceResult { + Data = combinationsModel, + Total = combinationsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here, checked before the combination lookup: matches the + // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity + // exists). Vendor's original combined the null-check and the ownership check into a single throw + // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson + // convention - this is a Kendo grid row-delete action - matching how Store's original reported + // denial (though Store checked access after the combination lookup, not before). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); + if (combination == null) + throw new ArgumentException("No product attribute combination found with the specified id"); + + await productAttributeService.DeleteProductAttributeCombination(combination, productId); + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + var pr = await productService.GetProductById(productId); + pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); + pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(pr, false); + } + + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AttributeCombinationPopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View inside a magnificPopup modal (not a grid), which would render + // raw JSON as page content on denial - using Content(...) instead, consistent with the + // EditAttributeValues GET popup correction in region "Product attribute values" above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AttributeCombinationPopup(string productId, + ProductAttributeCombinationModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + //No product found with the specified id + return RedirectToAction("List", "Product"); + + // Content(...) on denial, matching Store's original. Vendor's original folded denial into the + // same redirect used for a missing product; Content(...) surfaces the permissions message instead + // of silently redirecting, matching every other Edit-scoped action in this region. + // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement + // IProductValidVendor and has no registered FluentValidation validator, so there is no + // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); + if (!warnings.Any()) return Content(""); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + model.Warnings = warnings; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax + // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original + // returned Content(resource) on denial - a plain string the client's dataType:'json' parser + // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, + // showing a generic "Error while generating attribute combinations" alert. Vendor's original + // folded denial into the null-product throw (also an unhandled exception). Normalized here to a + // real JSON response, which avoids that parse-exception/generic-alert path - but note the + // .cshtml's ajax success callback for this action ignores the response body entirely (it just + // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a + // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either + // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for + // this controller-only row (flagged for Phase 2 view work). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.GenerateAllAttributeCombinations(product); + + return Json(new { Success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ClearAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.ClearAllAttributeCombinations(product); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = 0; + product.ReservedQuantity = 0; + await inventoryManageService.UpdateStockProduct(product, false); + } + + return Json(new { Success = true }); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product Attribute combination - tier prices + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, + string productId, string productAttributeCombinationId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check at all on this region (any of its + // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit + // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on + // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global + // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing + // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if + // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here + // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently + // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions + // (no-op for Admin, equivalent-or-stronger for Store/Vendor). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPriceModel = + await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, + productAttributeCombinationId); + var gridModel = new DataSourceResult { + Data = tierPriceModel, + Total = tierPriceModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceInsert(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check + // would have silently disappeared without an explicit scope.HasAccess call. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = + product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceUpdate(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceDelete(string productId, + string productAttributeCombinationId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both + // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + { + var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice != null) + await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, + tierPrice); + } + + return new JsonResult(""); + } + + #endregion + + #region Reservation + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListReservations(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; + // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var reservations = + await productReservationService.GetProductReservationsByProductId(productId, null, null, + command.Page - 1, command.PageSize); + var reservationModel = reservations + .Select(x => new ProductModel.ReservationModel { + ReservationId = x.Id, + Date = x.Date, + OrderId = x.OrderId, + ProductId = x.ProductId, + Parameter = x.Parameter, + Resource = x.Resource, + Duration = x.Duration + }).ToList(); + + var gridModel = new DataSourceResult { + Data = reservationModel, + Total = reservations.TotalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct + // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw + // ArgumentException for the combined null-or-access-denied case instead, which the client's + // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors + // message here is strictly more informative and closes Admin's original gap (no check at all). + if (!await scope.HasAccess(product)) + return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); + + var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); + if (reservations.Any()) + if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && + (IntervalUnit)model.Interval == IntervalUnit.Day) || + (product.IntervalUnitId == IntervalUnit.Day && + ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || + (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) + return Json(new { + errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") + }); + + if (!ModelState.IsValid) + { + var error = (Dictionary>)ModelState.SerializeErrors(); + var s = ""; + foreach (var error1 in error) + foreach (var error2 in error1.Value) + { + var v = (string[])error2.Value; + s += v[0] + "\n"; + } + + return Json(new { errors = s }); + } + + //update fields on product + await productService.UpdateProductField(product, x => x.Interval, model.Interval); + await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); + await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); + + var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { + IntervalUnit.Minute => model.Interval, + IntervalUnit.Hour => model.Interval * 60, + IntervalUnit.Day => model.Interval * 60 * 24, + _ => 0 + }; + + var _hourFrom = model.StartTime.Hour; + var _minutesFrom = model.StartTime.Minute; + var _hourTo = model.EndTime.Hour; + var _minutesTo = model.EndTime.Minute; + var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, + 0, 0, 0, 0); + var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, + 59, 999); + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + { + model.Quantity = 1; + model.Parameter = ""; + } + else + { + model.Resource = ""; + } + + var dates = new List(); + var counter = 0; + for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) + { + if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) + { + if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) + { + if (iterator.Hour == _hourTo) + if (iterator.Minute > _minutesTo) + continue; + if (iterator.Hour == _hourFrom) + if (iterator.Minute < _minutesFrom) + continue; + } + else + { + continue; + } + } + + if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || + (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || + (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || + (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || + (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || + (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || + (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) + continue; + + for (var i = 0; i < model.Quantity.MaxQuantity(); i++) + { + dates.Add(iterator); + try + { + var insert = true; + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) + insert = false; + if (insert) + { + if (counter++ > 1000) + break; + + await productReservationService.InsertProductReservation(new ProductReservation { + OrderId = "", + Date = iterator, + ProductId = productId, + Resource = model.Resource, + Parameter = model.Parameter, + Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) + }); + } + } + catch { } + } + } + + return Json(new { success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearCalendar(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads + // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, + // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A + // thrown exception at least surfaces a generic error via the ajax error callback. The view not + // reading a denial message is a pre-existing gap, out of scope for this migration. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearOld(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success + // callback doesn't read the response body either. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = + (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => + x.Date < DateTime.UtcNow); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductReservationDelete(ProductModel.ReservationModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservation(model.ReservationId); + + // Cross-product IDOR closed here: none of the three original hosts verified that the reservation + // being deleted (looked up purely by model.ReservationId) actually belongs to the product just + // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store + // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access + // check with that product's id while supplying a ReservationId belonging to a different, unowned + // product, deleting a reservation on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + await productReservationService.DeleteProductReservation(toDelete); + else + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") + }); + } + + return Json(""); + } + + #endregion + + #region Bids + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListBids(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's + // original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var (bidModels, totalCount) = + await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bidModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BidDelete(ProductModel.BidModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await auctionService.GetBid(model.BidId); + + // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the + // three original hosts verified that the bid being deleted (looked up purely by model.BidId) + // actually belongs to the product just access-checked (model.ProductId). Both are independent, + // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has + // access to *any* product pass the access check using that product's id while supplying a BidId + // belonging to a different, unowned product, deleting a bid on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + { + //delete bid + await auctionService.DeleteBid(toDelete); + return Json(""); + } + + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") + }); + } + + return Json(new DataSourceResult { Errors = "Bid not exists" }); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj index 8f965a46f..88786f07b 100644 --- a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +++ b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj @@ -1,7 +1,8 @@ - + enable + true diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs new file mode 100644 index 000000000..b9fda2ef5 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -0,0 +1,53 @@ +#nullable enable + +namespace Grand.Web.AdminShared.Interfaces; + +/// +/// Per-host data-access strategy for an admin-area entity. Implemented once per host +/// (Admin/Store/Vendor) and injected into shared AdminShared controllers/services so +/// scope logic lives in one place instead of being duplicated per host. +/// +public interface IAdminDataScope +{ + /// Whether the current user may mutate (edit/delete) this specific, already-loaded entity. + /// This is the strict check — for Store, matches AclMappingExtension.AccessToEntityByStore exactly + /// (denies global and multi-store entities, only the entity's exclusive single store passes). + Task HasAccess(TEntity entity); + + /// Whether the current user may view/reference this entity (open its edit form, copy it) — + /// looser than for hosts where viewing a shared/global entity is allowed but + /// mutating it isn't. Defaults to for hosts with no such split (Admin: always + /// true either way; Vendor: the two are identical, verified against the existing, unsplit + /// `CheckAccessToProduct`). Only Store overrides this. + Task CanView(TEntity entity) => HasAccess(entity); + + /// Store id to default onto new/edited entities. Null when the host has no store concept + /// (Admin: global, no default; Vendor: not store-scoped at all). + string? DefaultStoreId { get; } + + /// Prefix used to build host-specific localization keys, e.g. "Admin", "Vendor". Store + /// currently has no distinct resource set and uses "Admin" (see Task 6). + string ResourceKeyPrefix { get; } + + /// Whether the host's product list/search screens should offer a store picker at all. + /// True for Admin and Store (both operate within a store concept); false for Vendor (vendors don't + /// pick stores - the whole point of vendor scope is that it isn't a store id). This is a capability + /// flag, deliberately distinct from being null: DefaultStoreId is also + /// null for Admin (global, no default store), where the selector should still show. + bool ShowStoreSelector { get; } + + /// Vendor id to force onto product search/listing queries, overriding whatever a caller- + /// supplied search model asks for. Null when the host has no vendor concept (Admin: global; Store: + /// store-scoped, not vendor-scoped). Vendor: the current vendor's id - mirrors Vendor's original + /// service always passing vendorId: CurrentVendor.Id into IProductService.SearchProducts/ + /// PrepareProductList regardless of any vendor filter a client-supplied model field might carry, + /// so a vendor can never search or bulk-list another vendor's products. + string? DefaultVendorId { get; } + + /// Whether the host's product list/search screens should offer the "Show on homepage" + /// filter option. True for Global and Store; false for Vendor - vendors can't feature products + /// on the homepage, a real capability difference, not a naming difference. Replaces the earlier + /// `ResourceKeyPrefix != "Vendor"` check in ProductViewModelService, which overloaded a + /// localization-key property for behavior gating. + bool CanFeatureOnHomepage { get; } +} diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 36710c8a2..9d03ebb6d 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -11,7 +11,7 @@ public interface IProductViewModelService Task PrepareProductReviewModel(ProductReviewModel model, ProductReview productReview, bool excludeProperties, bool formatReviewText); - Task OutOfStockNotifications(Product product, ProductModel model, int prevStockQuantity, + Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock); Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, @@ -19,9 +19,9 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareTierPriceModel(ProductModel.TierPriceModel model, string storeId = ""); + Task PrepareTierPriceModel(ProductModel.TierPriceModel model); Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); - Task PrepareProductListModel(string storeId = ""); + Task PrepareProductListModel(); Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel(ProductListModel model, int pageIndex, int pageSize); @@ -31,7 +31,7 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task UpdateProductModel(Product product, ProductModel model); Task DeleteProduct(Product product); Task DeleteSelected(IEnumerable selectedIds); - Task PrepareAddRequiredProductModel(string storeId = ""); + Task PrepareAddRequiredProductModel(); Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, int pageIndex, int pageSize); @@ -59,13 +59,13 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteRecommendedProduct(string productId, string recommendedProductId); Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); Task DeleteAssociatedProduct(Product product); - Task PrepareRelatedProductModel(string storeId = ""); - Task PrepareSimilarProductModel(string storeId = ""); - Task PrepareBundleProductModel(string storeId = ""); - Task PrepareCrossSellProductModel(string storeId = ""); - Task PrepareRecommendedProductModel(string storeId = ""); - Task PrepareAssociatedProductModel(string storeId = ""); - Task PrepareBulkEditListModel(string storeId = ""); + Task PrepareRelatedProductModel(); + Task PrepareSimilarProductModel(); + Task PrepareBundleProductModel(); + Task PrepareCrossSellProductModel(); + Task PrepareRecommendedProductModel(); + Task PrepareAssociatedProductModel(); + Task PrepareBulkEditListModel(); Task<(IEnumerable bulkEditProductModels, int totalCount)> PrepareBulkEditProductModel( BulkEditListModel model, int pageIndex, int pageSize); @@ -75,7 +75,7 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteBulkEdit(IEnumerable products); //tierprices - Task> PrepareTierPriceModel(Product product, string storeId = ""); + Task> PrepareTierPriceModel(Product product); Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, int pageSize); @@ -117,7 +117,7 @@ Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMappi Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); Task - PrepareAssociateProductToAttributeValueModel(string storeId = ""); + PrepareAssociateProductToAttributeValueModel(); Task> PrepareProductAttributeCombinationModel(Product product); @@ -161,7 +161,7 @@ Task DeleteProductAttributeCombinationTierPrices(Product product, Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, Product product); - Task UpdateProductSpecificationAttributeModel(Product product, ProductSpecificationAttribute psa, + Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, ProductModel.AddProductSpecificationAttributeModel model); Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); diff --git a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs new file mode 100644 index 000000000..aeff35d49 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs @@ -0,0 +1,20 @@ +#nullable enable + +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class GlobalAdminDataScope : IAdminDataScope +{ + public Task HasAccess(TEntity entity) => Task.FromResult(true); + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Admin"; + + public bool ShowStoreSelector => true; + + public string? DefaultVendorId => null; + + public bool CanFeatureOnHomepage => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 426490681..9c85e49c5 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -71,7 +71,8 @@ public class ProductViewModelService( IAuctionService auctionService, IPriceFormatter priceFormatter, ISeNameService seNameService, - IEnumTranslationService enumTranslationService) + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) : IProductViewModelService { public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, @@ -139,14 +140,22 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; } - public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model, string storeId = "") + public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) { - if (string.IsNullOrEmpty(storeId)) - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + var storeId = scope.DefaultStoreId ?? ""; + + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareTierPriceModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + if (string.IsNullOrEmpty(storeId)) + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var store in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + foreach (var store in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } //customer groups model.AvailableCustomerGroups.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); @@ -175,7 +184,7 @@ public virtual async Task PrepareProductAttributeValueModel(Product product, model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; } - public virtual async Task OutOfStockNotifications(Product product, ProductModel model, int prevStockQuantity, + public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock ) { @@ -518,24 +527,30 @@ public virtual async Task PrepareProductReviewModel(ProductReviewModel model, } } - public virtual async Task PrepareProductListModel(string storeId = "") + public virtual async Task PrepareProductListModel() { var model = new ProductListModel(); + var storeId = scope.DefaultStoreId ?? ""; + + //stores - Vendor has no store concept at all (ShowStoreSelector is false), so the dropdown + //is left empty entirely rather than populated off storeId, which is "" for Vendor same as Admin. + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } - //stores - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var s in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); //warehouses - model.AvailableWarehouses.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + model.AvailableWarehouses.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); foreach (var wh in await warehouseService.GetAllWarehouses(storeId)) model.AvailableWarehouses.Add(new SelectListItem { Text = wh.Name, Value = wh.Id }); //product types model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "0" }); //"published" property //0 - all (according to "ShowHidden" parameter) @@ -543,21 +558,26 @@ public virtual async Task PrepareProductListModel(string store //2 - unpublished only //3 - Show on homepage //4 - mark as new - model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.All"), Value = " " }); + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.All"), Value = " " }); model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.PublishedOnly"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.PublishedOnly"), Value = "1" }); model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.UnpublishedOnly"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.UnpublishedOnly"), Value = "2" }); + + // Admin/Store show "Show on homepage" (value 3); Vendor's original copy omits it entirely - vendors + // can't feature products on the homepage, a real capability difference, not a naming difference. + if (scope.CanFeatureOnHomepage) + model.AvailablePublishedOptions.Add(new SelectListItem { + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.ShowOnHomePage"), + Value = "3" + }); + model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.ShowOnHomePage"), - Value = "3" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.MarkAsNew"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.MarkAsNew"), Value = "4" }); @@ -596,12 +616,16 @@ public virtual async Task PrepareProductListModel(string store var markedAsNewOnly = model.SearchPublishedId == 4; + // vendorId: scope.DefaultVendorId (when set) overrides whatever the search model asks for - mirrors + // Vendor's original PrepareProductsModel, which always passed vendorId: CurrentVendor.Id regardless + // of any vendor filter on the model. Admin/Store have no DefaultVendorId, so this falls back to + // model.SearchVendorId unchanged for them. var products = (await productService.SearchProducts( categoryIds: categoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, - vendorId: model.SearchVendorId, + vendorId: scope.DefaultVendorId ?? model.SearchVendorId, warehouseId: model.SearchWarehouseId, productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, @@ -661,12 +685,14 @@ public virtual async Task> PrepareProducts(ProductListModel model break; } + // vendorId: same override as PrepareProductsModel above - mirrors Vendor's original PrepareProducts, + // which always passed vendorId: CurrentVendor.Id regardless of the search model. var products = (await productService.SearchProducts( categoryIds: categoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, - vendorId: model.SearchVendorId, + vendorId: scope.DefaultVendorId ?? model.SearchVendorId, warehouseId: model.SearchWarehouseId, productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, @@ -682,6 +708,16 @@ public virtual async Task InsertProductModel(ProductModel model) //product var product = model.ToEntity(dateTimeService); + // VendorId: forced onto new products when scope.DefaultVendorId is set - mirrors Vendor's original + // InsertProductModel, which always set product.VendorId = CurrentVendor?.Id explicitly and ignored + // VendorId in its AutoMapper profile for exactly this reason. AdminShared's shared ProductProfile + // does *not* ignore ProductModel.VendorId -> Product.VendorId (Admin's create form legitimately + // assigns vendor ownership), so without this, a vendor host would map whatever (usually blank) + // VendorId the model carries, leaving newly-created products belonging to no vendor at all - + // orphaned and inaccessible to the vendor who just created them once VendorProductDataScope.HasAccess + // gates on VendorId equality. + if (scope.DefaultVendorId is not null) product.VendorId = scope.DefaultVendorId; + //discounts var allDiscounts = await discountService.GetDiscountsQuery(DiscountType.AssignedToSkus, model.StoreId); foreach (Discount discount in allDiscounts) @@ -721,6 +757,16 @@ public virtual async Task UpdateProductModel(Product product, ProductMo //product product = model.ToEntity(product, dateTimeService); product.AutoAddRequiredProducts = model.AutoAddRequiredProducts; + + // VendorId: re-forced to scope.DefaultVendorId (when set) after mapping, not merely left alone - + // AdminShared's shared ProductProfile does not ignore ProductModel.VendorId -> Product.VendorId, so + // without this, mapping a caller-supplied model onto the existing entity would let a vendor + // reassign their own product to a different vendor id via a tampered/mass-assigned VendorId field. + // Vendor's original UpdateProductModel never touched VendorId at all (its own AutoMapper profile + // ignores it), relying on the pre-loaded entity's existing value - forcing it here is strictly + // equivalent for legitimately-owned products (already gated by scope.HasAccess before reaching this + // method) while also closing that mass-assignment gap for Admin/Store's shared profile. + if (scope.DefaultVendorId is not null) product.VendorId = scope.DefaultVendorId; product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); //discounts @@ -756,7 +802,7 @@ public virtual async Task UpdateProductModel(Product product, ProductMo await UpdatePictureSeoNames(product); //out of stock notifications - await OutOfStockNotifications(product, model, prevStockQuantity, prevMultiWarehouseStock); + await OutOfStockNotifications(product, prevStockQuantity, prevMultiWarehouseStock); //delete an old "download" file (if deleted or updated) if (!string.IsNullOrEmpty(prevDownloadId) && prevDownloadId != product.DownloadId) @@ -807,17 +853,21 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) } } - public virtual async Task PrepareAddRequiredProductModel(string storeId = "") + public virtual async Task PrepareAddRequiredProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } public virtual async Task<(IList products, int totalCount)> PrepareProductModel( ProductModel.AddProductModel model, int pageIndex, int pageSize) { + // vendorId: scope.DefaultVendorId (when set) overrides the search model - mirrors Vendor's original + // PrepareProductModel(AddProductModel,...), which always passed vendorId: CurrentVendor.Id (and + // storeId: string.Empty) regardless of the model, so the "add related/similar/bundle/etc. product" + // popups can never surface another vendor's products. var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, - model.SearchCollectionId, model.SearchStoreId, model.SearchVendorId, model.SearchProductTypeId, + model.SearchCollectionId, model.SearchStoreId, scope.DefaultVendorId ?? model.SearchVendorId, model.SearchProductTypeId, model.SearchProductName, pageIndex, pageSize); return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); } @@ -946,20 +996,22 @@ public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProdu foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingRelatedProducts = productId1.RelatedProducts; - if (model.ProductId != id) - if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) - { - var related = new RelatedProduct { - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.RelatedProducts.Add(related); - await productService.InsertRelatedProduct(related, model.ProductId); - } - } + // scope.HasAccess: same per-id ownership filter as BaseProductController's + // AssociatedProductAddPopup(POST) selected-ids loop - without it a vendor could map another + // vendor's products into their own related-products list (ARCH-001 Phase 1, Task 11). + if (product == null || !await scope.HasAccess(product)) continue; + + var existingRelatedProducts = productId1.RelatedProducts; + if (model.ProductId != id) + if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) + { + var related = new RelatedProduct { + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.RelatedProducts.Add(related); + await productService.InsertRelatedProduct(related, model.ProductId); + } } } @@ -995,21 +1047,21 @@ public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProdu foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingSimilarProducts = productId1.SimilarProducts; - if (model.ProductId != id) - if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) - { - var similar = new SimilarProduct { - ProductId1 = model.ProductId, - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.SimilarProducts.Add(similar); - await productService.InsertSimilarProduct(similar); - } - } + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingSimilarProducts = productId1.SimilarProducts; + if (model.ProductId != id) + if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) + { + var similar = new SimilarProduct { + ProductId1 = model.ProductId, + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.SimilarProducts.Add(similar); + await productService.InsertSimilarProduct(similar); + } } } @@ -1047,21 +1099,21 @@ public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProduct foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingBundleProducts = productId1.BundleProducts; - if (model.ProductId != id) - if (!existingBundleProducts.Any(x => x.ProductId == id)) - { - var bundle = new BundleProduct { - ProductId = id, - DisplayOrder = 1, - Quantity = 1 - }; - productId1.BundleProducts.Add(bundle); - await productService.InsertBundleProduct(bundle, model.ProductId); - } - } + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingBundleProducts = productId1.BundleProducts; + if (model.ProductId != id) + if (!existingBundleProducts.Any(x => x.ProductId == id)) + { + var bundle = new BundleProduct { + ProductId = id, + DisplayOrder = 1, + Quantity = 1 + }; + productId1.BundleProducts.Add(bundle); + await productService.InsertBundleProduct(bundle, model.ProductId); + } } } @@ -1098,14 +1150,16 @@ public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellP foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertCrossSellProduct( - new CrossSellProduct { - ProductId1 = model.ProductId, - ProductId2 = id - }); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertCrossSellProduct( + new CrossSellProduct { + ProductId1 = model.ProductId, + ProductId2 = id + }); } } @@ -1124,10 +1178,12 @@ public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommen foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - if (!mainproduct.RecommendedProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertRecommendedProduct(model.ProductId, id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!mainproduct.RecommendedProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertRecommendedProduct(model.ProductId, id); } } @@ -1155,44 +1211,45 @@ public virtual async Task DeleteAssociatedProduct(Product product) await productService.UpdateAssociatedProduct(product); } - public virtual async Task PrepareRelatedProductModel(string storeId = "") + public virtual async Task PrepareRelatedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareSimilarProductModel(string storeId = "") + public virtual async Task PrepareSimilarProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareBundleProductModel(string storeId = "") + public virtual async Task PrepareBundleProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareCrossSellProductModel(string storeId = "") + public virtual async Task PrepareCrossSellProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareRecommendedProductModel(string storeId = "") + public virtual async Task PrepareRecommendedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareAssociatedProductModel(string storeId = "") + public virtual async Task PrepareAssociatedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareBulkEditListModel(string storeId = "") + public virtual async Task PrepareBulkEditListModel() { + var storeId = scope.DefaultStoreId ?? ""; var model = new BulkEditListModel(); //product types @@ -1200,19 +1257,24 @@ public virtual async Task PrepareBulkEditListModel(string sto model.AvailableProductTypes.Insert(0, new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); - // avaible stores - if (!string.IsNullOrEmpty(storeId)) + // avaible stores - same capability gate as PrepareProductListModel: Vendor's original never + // populated a stores dropdown here at all (vendors don't pick stores), so this must not run for + // Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) { - var store = (await storeService.GetAllStores()).FirstOrDefault(x => x.Id == storeId); - if (store != null) - model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); - } - else - { - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + if (!string.IsNullOrEmpty(storeId)) + { + var store = (await storeService.GetAllStores()).FirstOrDefault(x => x.Id == storeId); + if (store != null) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } + else + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var s in await storeService.GetAllStores()) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + foreach (var s in await storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } } return model; @@ -1225,10 +1287,17 @@ public virtual async Task PrepareBulkEditListModel(string sto if (!string.IsNullOrEmpty(model.SearchCategoryId)) searchCategoryIds.Add(model.SearchCategoryId); + // vendorId: scope.DefaultVendorId forces the bulk-edit grid to the current vendor's own products. + // Vendor's original PrepareBulkEditProductModel always passed vendorId: CurrentVendor.Id; this + // shared version previously had no vendor filtering at all - a real gap, since BulkEditListModel + // has no SearchVendorId field for a caller to (mis)supply in the first place, so there is nothing + // to fall back to for Admin/Store, whose scope.DefaultVendorId is null and who intentionally see + // every vendor's products in bulk-edit. var products = (await productService.SearchProducts(categoryIds: searchCategoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, + vendorId: scope.DefaultVendorId ?? "", productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, pageIndex: pageIndex - 1, @@ -1296,8 +1365,9 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ } } - public virtual async Task> PrepareTierPriceModel(Product product, string storeId = "") + public virtual async Task> PrepareTierPriceModel(Product product) { + var storeId = scope.DefaultStoreId ?? ""; var items = new List(); foreach (var x in product.TierPrices .Where(x => x.StoreId == storeId || string.IsNullOrWhiteSpace(storeId) || @@ -1868,9 +1938,9 @@ await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, model.ProductAttributeMappingId); } - public virtual async Task PrepareAssociateProductToAttributeValueModel(string storeId = "") + public virtual async Task PrepareAssociateProductToAttributeValueModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } @@ -2330,6 +2400,14 @@ public virtual async Task InsertProductPicture(Product product, Picture picture, if (product.ProductPictures.Any(x => x.PictureId == picture.Id)) return; + // IsDefault: deliberately left unset here (defaults to false), matching Admin/Store's original + // InsertProductPicture. Vendor's original copy set `IsDefault = product.ProductPictures.Any()` - + // true only once a picture already exists, i.e. false on the very first upload and true on every + // one after that. That reads as inverted (the first picture is normally the one that should + // default to true) rather than as an intentional feature Admin/Store were missing, so it is not + // being ported here. This is a documented behavior decision (ARCH-001 Phase 1, Task 11), not an + // oversight - flag for product-team review if Vendor's admins report the default-picture picker + // behaving differently than before. var productPicture = new ProductPicture { PictureId = picture.Id, DisplayOrder = displayOrder @@ -2445,8 +2523,8 @@ public virtual async Task InsertProductSpecificationAttributeModel( product.ProductSpecificationAttributes.Add(psa); } - public virtual async Task UpdateProductSpecificationAttributeModel(Product product, - ProductSpecificationAttribute psa, ProductModel.AddProductSpecificationAttributeModel model) + public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, + ProductModel.AddProductSpecificationAttributeModel model) { psa = model.ToEntity(psa); await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); @@ -2547,20 +2625,32 @@ protected virtual async Task SaveProductTags(Product product, string[] productTa } } - protected virtual async Task PrepareAddProductModel(string storeId = "") where T : ProductModel.AddProductModel, new() + protected virtual async Task PrepareAddProductModel() where T : ProductModel.AddProductModel, new() { + var storeId = scope.DefaultStoreId ?? ""; var model = new T(); - //stores - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var s in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareAddProductModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } - //vendors - model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var v in await vendorService.GetAllVendors(showHidden: true)) - model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + //vendors - only when the host isn't already forced onto a single vendor (scope.DefaultVendorId): + //Vendor's original PrepareAddProductModel never populated a vendor picker at all (matches the + //ShowStoreSelector gate above for the same reason - there's nothing to pick, the search is always + //forced to the current vendor). + if (scope.DefaultVendorId is null) + { + model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var v in await vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } //product types model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs new file mode 100644 index 000000000..dfd189aae --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs @@ -0,0 +1,65 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host implementation at +/// request time, based on the current request's "area" route value, instead of relying on +/// DI registration order. +/// +/// Why this exists: Grand.Web (the combined host, run as "grand-web" in Aspire) references +/// Grand.Web.Admin, Grand.Web.Store, and Grand.Web.Vendor together in one process/one DI +/// container. Each host's own StartupApplication used to register +/// AddScoped<IAdminDataScope<Product>, X>() directly - plain AddScoped doesn't replace an +/// earlier registration, it appends one, so whichever host's StartupApplication ran last (by +/// IStartupApplication.Priority - Vendor's is highest) silently won for every area in that +/// process. That surfaced as a NullReferenceException in VendorProductDataScope.DefaultVendorId +/// when an Admin user opened the product list under the combined host, because Vendor's scope +/// assumes WorkContext.CurrentVendor is set. +/// +/// Fix: register the three concrete scopes as themselves (not as IAdminDataScope<Product>) +/// and register this resolver as the single IAdminDataScope<Product> - see +/// Grand.Web.AdminShared/Startup/StartupApplication.cs. Each host's own StartupApplication no +/// longer registers IAdminDataScope<Product> at all. +/// +public class RoutedProductDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreAdminDataScope storeScope, + VendorProductDataScope vendorScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => globalScope, + "Store" => storeScope, + "Vendor" => vendorScope, + //fail closed: this object fronts vendor/store tenant isolation, so an unrecognized + //or missing area must never silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedProductDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(Product entity) => Resolved.HasAccess(entity); + + public Task CanView(Product entity) => Resolved.CanView(entity); + + public string? DefaultStoreId => Resolved.DefaultStoreId; + + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + + public string? DefaultVendorId => Resolved.DefaultVendorId; + + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs new file mode 100644 index 000000000..334757c9a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -0,0 +1,41 @@ +#nullable enable + +using Grand.Domain; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class StoreAdminDataScope(IContextAccessor contextAccessor) : IAdminDataScope + where TEntity : BaseEntity, IStoreLinkEntity +{ + public Task HasAccess(TEntity entity) + { + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + return Task.FromResult(entity != null && entity.AccessToEntityByStore(staffStoreId)); + } + + /// Looser than : a global entity or one shared across multiple + /// stores (including the staff member's) may be viewed; only an entity limited to stores that + /// exclude the staff member's store is denied. Mirrors Store's original Edit(GET)/CopyProduct + /// behavior (see Grand.Web.Store's ProductController pre-consolidation). + public Task CanView(TEntity entity) + { + if (entity is null) return Task.FromResult(false); + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); + return Task.FromResult(allowed); + } + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public string ResourceKeyPrefix => "Admin"; + + public bool ShowStoreSelector => true; + + public string? DefaultVendorId => null; + + public bool CanFeatureOnHomepage => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs new file mode 100644 index 000000000..d9c913167 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs @@ -0,0 +1,26 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class VendorProductDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Product entity) + { + if (entity is null) return Task.FromResult(false); + return Task.FromResult(entity.VendorId == contextAccessor.WorkContext.CurrentVendor.Id); + } + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Vendor"; + + public bool ShowStoreSelector => false; + + public string? DefaultVendorId => contextAccessor.WorkContext.CurrentVendor.Id; + + public bool CanFeatureOnHomepage => false; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 38ec7c761..51937c430 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -1,5 +1,6 @@ using elFinder.Net.AspNetCore.Extensions; using elFinder.Net.Drivers.FileSystem.Extensions; +using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Services; @@ -58,6 +59,17 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); + // IAdminDataScope: registered once here (not per-host) via a route-driven resolver. + // Grand.Web (the combined host) references Admin, Store, and Vendor together in one DI + // container, so three competing AddScoped, X>() calls (one per host's + // own StartupApplication) would just have the last-registered host silently win for every + // area - see RoutedProductDataScope's doc comment for the NullReferenceException this caused. + // The three concrete scopes are registered as themselves so the resolver can pick between them + // per-request based on the "area" route value. + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped(); + services.AddScoped, RoutedProductDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml index c6204bb47..dbf06b79d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel +@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; } -
@@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]
@@ -33,10 +34,10 @@
@@ -63,18 +64,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -131,8 +135,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -149,7 +156,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("AssociateProductToAttributeValuePopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("AssociateProductToAttributeValuePopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -187,15 +194,15 @@ scrollable: false, columns: [{ field: "Name", - title: "@Loc["Admin.Common.Select"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Select"]", width: 50, - template: '' + template: '' },{ field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -218,4 +225,4 @@ }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml index adac34c4c..186039b2f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.AddAssociatedProductModel +@model ProductModel.AddAssociatedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]; } -
@@ -18,7 +19,7 @@
- @Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]
@@ -36,13 +37,13 @@
- +
@@ -67,18 +68,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -131,15 +135,18 @@ $('.filter-text-close').toggle(); }); - + function additionalData() { var data = { SearchProductName: $('#@Html.IdFor(model => model.SearchProductName)').val(), SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -156,7 +163,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("AssociatedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("AssociatedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -197,11 +204,11 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]", - template: "#:Name# # if(AssociatedToProductName !== null) {#
@Loc["Admin.Catalog.Products.Fields.AssociatedToProductName"]: #:AssociatedToProductName# #} #" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]", + template: "#:Name# # if(AssociatedToProductName !== null) {#
@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.AssociatedToProductName"]: #:AssociatedToProductName# #} #" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -234,4 +241,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BulkEdit.cshtml similarity index 84% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BulkEdit.cshtml index 12a09140c..b9c11fe4f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BulkEdit.cshtml @@ -1,11 +1,12 @@ -@using System.Text.Encodings.Web +@using System.Text.Encodings.Web @model BulkEditListModel @inject AdminAreaSettings adminAreaSettings @{ //page title - ViewBag.Title = Loc["Admin.Catalog.BulkEdit"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -13,9 +14,9 @@
- @Loc["Admin.Catalog.BulkEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit"]
- +
@@ -32,10 +33,10 @@
@@ -68,12 +69,15 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
+ }
@@ -107,18 +111,18 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BulkEditSelect", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditSelect", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData }, update: { - url: "@Html.Raw(Url.Action("BulkEditUpdate", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditUpdate", "Product", new { area = area }))", type: "POST", dataType: "json" }, destroy: { - url: "@Html.Raw(Url.Action("BulkEditDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditDelete", "Product", new { area = area }))", type: "POST", dataType: "json" }, @@ -198,20 +202,20 @@ scrollable: true, columns: [ { field: "Name", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Name"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Name"]", width: 200 }, { field: "ProductId", - title: "@Loc["Admin.Common.View"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.View"]", width: 80, - template: '@Loc["Admin.Common.View"]' + template: '@Loc[$"{Scope.ResourceKeyPrefix}.Common.View"]' },{ field: "Sku", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Sku"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Sku"]", width: 100 }, { field: "Price", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Price"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Price"]", width: 100, editor: function (container, options) { $('') @@ -223,7 +227,7 @@ } }, { field: "OldPrice", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.OldPrice"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.OldPrice"]", width: 100, editor: function (container, options) { $('') @@ -235,13 +239,13 @@ } }, { field: "StockQuantity", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.StockQuantity"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.StockQuantity"]", //integer format format: "{0:0}", width: 100 }, { field: "ManageInventoryMethod", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.ManageInventoryMethod"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.ManageInventoryMethod"]", width: 150, editor: function (container, options) { $("") @@ -260,11 +264,11 @@ } },{ field: "Published", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Published"]", width: 90 }, { - command: { name: "destroy", text: "@Loc["Admin.Common.Delete"]" }, - title: "@Loc["Admin.Common.Delete"]", + command: { name: "destroy", text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }, + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]", width: 100 } ] @@ -297,10 +301,13 @@ SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), SearchProductTypeId: $('#SearchProductTypeId').val(), - SearchStoreId: $('#SearchStoreId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + } }; addAntiForgeryToken(data); return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml index 80ae8d649..e299760f8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/BundleProductAddPopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.AddBundleProductModel +@model ProductModel.AddBundleProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.BundleProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.BundleProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -122,7 +126,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BundleProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BundleProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -161,10 +165,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -204,8 +208,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val() + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val() + } }; addAntiForgeryToken(data); return data; @@ -217,4 +224,4 @@ $('#mastercheckbox').prop('checked', numChkBoxes == numChkBoxesChecked && numChkBoxes > 0); } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Create.cshtml similarity index 65% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Create.cshtml index 718f3ce5b..392252abb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Create.cshtml @@ -1,9 +1,10 @@ -@model ProductModel +@model ProductModel @{ //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -11,20 +12,20 @@
- @Loc["Admin.Catalog.Products.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AddNew"] - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") + @Html.ActionLink(Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BackToList"], "List")
- +
@@ -34,4 +35,4 @@
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml index 2b5c6b71c..83b81c433 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddCrossSellProductModel +@model ProductModel.AddCrossSellProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.CrossSells.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.CrossSells.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -124,15 +128,18 @@ } }); }); - + function additionalData() { var data = { SearchProductName: $('#@Html.IdFor(model => model.SearchProductName)').val(), SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -148,7 +155,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("CrossSellProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("CrossSellProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -180,7 +187,7 @@ scrollable: false, columns: [{ field: "Id", - title: "@Loc["Admin.Common.Check"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Check"]", headerTemplate: "", headerAttributes: { style: "text-align:center" }, template: "", @@ -188,10 +195,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -225,4 +232,4 @@ }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml similarity index 77% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml index 9a1a7e6bd..8a81ae02a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Edit.cshtml @@ -1,9 +1,10 @@ -@model ProductModel +@model ProductModel @{ //page title - ViewBag.Title = Loc["Admin.Catalog.Products.EditProductDetails"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.EditProductDetails"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -11,31 +12,31 @@
- @Loc["Admin.Catalog.Products.EditProductDetails"] - @Model.Name + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.EditProductDetails"] - @Model.Name - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") + @Html.ActionLink(Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BackToList"], "List")
- @Loc["Admin.Common.Delete"] + @Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"] - +
@@ -49,7 +50,7 @@ @@ -90,11 +91,11 @@ window.kendoWindow({ modal: true, width: "400px", - title: "@Loc["Admin.Catalog.Products.Copy"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Copy"]", actions: ["Close"] }); } window.data('kendoWindow').center().open(); }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.Downloads.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.Downloads.cshtml new file mode 100644 index 000000000..9e2e2f7c1 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Additional.Downloads.cshtml @@ -0,0 +1,88 @@ +@using Grand.Domain.Media +@model ProductModel +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml new file mode 100644 index 000000000..b46a801f7 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml @@ -0,0 +1,8 @@ +{ + field: "IsFeaturedProduct", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Categories.Fields.IsFeaturedProduct"]", + width: 100, + headerAttributes: { style: "text-align:center" }, + attributes: { style: "text-align:center" }, + template: '# if(IsFeaturedProduct) {# #} else {# #} #' +}, diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.LinkTemplate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.LinkTemplate.cshtml new file mode 100644 index 000000000..697507bfb --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.LinkTemplate.cshtml @@ -0,0 +1,4 @@ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +template: '#:Category#' diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml new file mode 100644 index 000000000..6490a61dd --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml @@ -0,0 +1,8 @@ +{ + field: "IsFeaturedProduct", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.IsFeaturedProduct"]", + width: 100, + headerAttributes: { style: "text-align:center" }, + attributes: { style: "text-align:center" }, + template: '# if(IsFeaturedProduct) {# #} else {# #} #' +}, diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.LinkTemplate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.LinkTemplate.cshtml new file mode 100644 index 000000000..64efce9c5 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.LinkTemplate.cshtml @@ -0,0 +1,4 @@ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +template: '#:Collection#' diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Additional.cshtml similarity index 93% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Additional.cshtml index 0216a0c32..ffb86592a 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Additional.cshtml @@ -1,10 +1,11 @@ -@using Grand.Domain.Media +@using Grand.Domain.Media @model ProductModel @{ ViewData["DownloadType"] = DownloadType.Product; ViewData["ReferenceId"] = Model.Id; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } - +
@@ -66,6 +67,8 @@
+ +
@@ -121,12 +124,12 @@ - @Loc["Vendor.Catalog.Products.Fields.RequiredProductIds.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.AddNew"] - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Inventory.cshtml similarity index 91% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Inventory.cshtml index 37a5f2909..a637cc14f 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Inventory.cshtml @@ -1,5 +1,5 @@ -@model ProductModel - +@model ProductModel +
@@ -35,16 +35,16 @@ - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] @@ -76,18 +76,18 @@

- @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Description1"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Description1"]

- @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Description2"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Description2"]

- @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Description3"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Description3"]

} else { - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] }
@@ -275,4 +275,4 @@
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml similarity index 81% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml index 068149512..7335ec3e5 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml @@ -1,13 +1,15 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- +

- @Loc["Admin.Catalog.Products.Pictures.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Pictures.AddNew"]

@@ -133,7 +135,7 @@ @{ ViewData["Reference"] = "Product"; ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaStore }); + ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = area }); ViewData["Click"] = "btnRefreshProductPictures"; }
@@ -160,6 +162,6 @@ else {
- @Loc["Admin.Catalog.Products.Pictures.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Pictures.SaveBeforeEdit"]
-} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml similarity index 78% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml index ad33ef35c..0ab25e153 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml @@ -1,25 +1,28 @@ @model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}

- @Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Description"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.Description"]

- @Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Description2"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.Description2"]

- +
- +
+} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml similarity index 79% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml index d061a6519..3a225905e 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Recommended.cshtml @@ -1,20 +1,22 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- @Loc["Admin.Catalog.Products.Recommended"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended"]
- +
\ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SEO.cshtml similarity index 94% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SEO.cshtml index 509467fe0..1fc2f5990 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SEO.cshtml @@ -1,6 +1,6 @@ -@using Microsoft.AspNetCore.Mvc.Razor +@using Microsoft.AspNetCore.Mvc.Razor @model ProductModel - + @{ Func @@ -71,4 +71,4 @@
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml similarity index 79% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml index c6f1c40a6..c982a474f 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml @@ -1,17 +1,20 @@ @model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- @Loc["Vendor.Catalog.Products.SimilarProducts"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts"]
@@ -35,19 +38,19 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("SimilarProductList", "Product", new { productId = Model.Id, area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("SimilarProductList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, update: { - url:"@Html.Raw(Url.Action("SimilarProductUpdate", "Product", new { area = Constants.AreaVendor }))", + url:"@Html.Raw(Url.Action("SimilarProductUpdate", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("SimilarProductDelete", "Product", new { area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("SimilarProductDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -94,11 +97,11 @@ scrollable: false, columns: [{ field: "Product2Name", - title: "@Loc["Vendor.Catalog.Products.SimilarProducts.Fields.Product"]", - template: '#=kendo.htmlEncode(Product2Name)#', + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.Fields.Product"]", + template: '#=kendo.htmlEncode(Product2Name)#', }, { field: "DisplayOrder", - title: "@Loc["Vendor.Catalog.Products.SimilarProducts.Fields.DisplayOrder"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.Fields.DisplayOrder"]", //integer format format: "{0:0}", width: 120, @@ -108,13 +111,13 @@ command: [{ name: "edit", text: { - edit: "@Loc["Vendor.Common.Edit"]", - update: "@Loc["Vendor.Common.Update"]", - cancel: "@Loc["Vendor.Common.Cancel"]" + edit: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Edit"]", + update: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Update"]", + cancel: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Cancel"]" } }, { name: "destroy", - text: "@Loc["Vendor.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }] }] }); @@ -137,6 +140,6 @@ else {
- @Loc["Vendor.Catalog.Products.SimilarProducts.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.SaveBeforeEdit"]
} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml similarity index 81% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml index e4a587b9e..3fb6bf6fc 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml @@ -1,15 +1,18 @@ @model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) { @@ -34,13 +37,13 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ProductSpecAttrList", "Product", new { productId = Model.Id, area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("ProductSpecAttrList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("ProductSpecAttrDelete", "Product", new { area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("ProductSpecAttrDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -108,22 +111,22 @@ scrollable: false, columns: [{ field: "AttributeTypeName", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.AttributeType"]", - template: "#=kendo.htmlEncode(AttributeTypeName)#" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.AttributeType"]", + template: "#=kendo.htmlEncode(AttributeTypeName)#" }, { field: "ValueRaw", encoded: false, - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.Value"]", - template: "#=kendo.htmlEncode(AttributeName)# : #=ValueRaw# " + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.Value"]", + template: "#=kendo.htmlEncode(AttributeName)# : #=ValueRaw# " }, { field: "AllowFiltering", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering"]", headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(AllowFiltering) {# #} else {# #} #' }, { field: "ShowOnProductPage", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage"]", headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(ShowOnProductPage) {# #} else {# #} #' @@ -131,7 +134,7 @@ command: [ { name: "destroy", - text: "@Loc["Vendor.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" } ] }] @@ -154,6 +157,6 @@ else {
- @Loc["Vendor.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"]
} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.cshtml similarity index 64% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.cshtml index 642c1c1a7..304d6217d 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.cshtml @@ -1,46 +1,45 @@ -@model ProductModel - +@model ProductModel
- +
- +
- +
- +
- +
- +
@@ -48,49 +47,49 @@
- +
- +
- +
- +
- +
- +
- +
@@ -100,7 +99,7 @@
- +
@@ -109,21 +108,23 @@ @if (!string.IsNullOrEmpty(Model.Id)) { - +
- +
+ + } - + - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml similarity index 94% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml index 6b635414f..a30ce9d09 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml @@ -1,5 +1,8 @@ @using Microsoft.AspNetCore.Mvc.Razor @model ProductModel.ProductAttributeValueModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
@@ -76,8 +79,8 @@ @Model.AssociatedProductName - model.AssociatedProductId), productNameInput = "associate-product-name", area = Constants.AreaStore }))" class="k-button"> - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"] + model.AssociatedProductId), productNameInput = "associate-product-name", area = area }))" class="k-button"> + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]
@@ -185,7 +188,7 @@

@@ -213,9 +216,9 @@
- +
diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateTierPrice.cshtml similarity index 81% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateTierPrice.cshtml index 3503bb67f..3f7cfc78d 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateTierPrice.cshtml @@ -1,11 +1,11 @@ -@model ProductModel.TierPriceModel +@model ProductModel.TierPriceModel
- +
@@ -28,6 +28,7 @@
+
@@ -45,11 +46,11 @@
- +
- -
\ No newline at end of file + +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/ProductAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.CustomerLinkTemplate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.CustomerLinkTemplate.cshtml new file mode 100644 index 000000000..20c67efee --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.CustomerLinkTemplate.cshtml @@ -0,0 +1,4 @@ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +template: '#:CustomerInfo#', diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.TitleLinkTemplate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.TitleLinkTemplate.cshtml new file mode 100644 index 000000000..f3c0fc5bd --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Reviews.TitleLinkTemplate.cshtml @@ -0,0 +1,4 @@ +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +template: '#:Title#', diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.Documents.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.Documents.cshtml new file mode 100644 index 000000000..f41e89669 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.Documents.cshtml @@ -0,0 +1,17 @@ +@using Grand.Business.Core.Interfaces.Common.Security +@using Grand.Domain.Permissions +@model ProductModel +@inject IPermissionService permissionService +@{ + var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); +} +@if (canManageDocuments) +{ + + +
+ +
+
+
+} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.UserFields.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.UserFields.cshtml new file mode 100644 index 000000000..6cae6a420 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Tab.UserFields.cshtml @@ -0,0 +1,8 @@ +@model ProductModel + + +
+ +
+
+
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/TierPrice.StoreScope.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/TierPrice.StoreScope.cshtml new file mode 100644 index 000000000..e37ac65f1 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/TierPrice.StoreScope.cshtml @@ -0,0 +1,15 @@ +@model ProductModel.TierPriceModel +
+ +
+ + +
+
+
+ +
+ + +
+
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 000000000..624b8d991 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1 @@ +@* Unreachable in practice: Admin, Store, and (except Discounts/Documents) Vendor all carry their own real override of this file, which wins by view-location precedence. Kept as the documented fallback for a future host with no widget component of its own - see ARCH-001 Phase 2 spec section 5.2/7. *@ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml index 52e99442d..65b9ba8bc 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml @@ -1,9 +1,12 @@ @model ProductAttributeConditionModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]; } -
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition.Description"]
@@ -145,7 +148,7 @@
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeMappingPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeMappingPopup.cshtml index a08a7d827..eb488bca7 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeMappingPopup.cshtml @@ -1,10 +1,13 @@ @model ProductModel.ProductAttributeMappingModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Details"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Details"]; } - @if (string.IsNullOrEmpty(Model.Id)) { - @Loc["Admin.Catalog.Products.ProductAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] } else { - @Loc["Admin.Catalog.Products.ProductAttributes.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Edit"] }
@@ -95,7 +98,7 @@
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValidationRulesPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValidationRulesPopup.cshtml index 46e2c184b..6c5cdf3c1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValidationRulesPopup.cshtml @@ -1,12 +1,15 @@ @model ProductModel.ProductAttributeMappingModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; var attributeControlType = Model.AttributeControlTypeId; } - @@ -17,7 +20,7 @@
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]
@@ -62,7 +65,7 @@
diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml similarity index 83% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml index 9e3cff0d9..a96a02328 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml @@ -1,10 +1,13 @@ @model ProductModel.ProductAttributeValueModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; } -
- @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueEditPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueEditPopup.cshtml index 187584377..7bc48dd2c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueEditPopup.cshtml @@ -1,10 +1,11 @@ @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; } @model ProductModel.ProductAttributeValueModel -
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml index 8f4de15f9..6ce8f8fa2 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml @@ -2,8 +2,9 @@ @model ProductModel.ProductPictureModel @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
- @Loc["Admin.Catalog.Products.Pictures.Details"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Pictures.Details"]
@@ -108,7 +109,7 @@
diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml index a23923282..2da518284 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml @@ -1,8 +1,9 @@ @model ProductModel.AddProductSpecificationAttributeModel @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } - @if (string.IsNullOrEmpty(Model.Id)) { - @Loc["Vendor.Catalog.Products.SpecificationAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddNew"] } else { - @Loc["Vendor.Catalog.Products.SpecificationAttributes.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Edit"] }
@@ -96,7 +97,7 @@
@@ -143,7 +144,7 @@ $.ajax({ cache: false, type: "GET", - url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = Constants.AreaVendor }))", + url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = area }))", data: { "attributeId": selectedAttributeId }, success: function (data) { var ddlSpecOptions = $("#@Html.IdFor(model => model.SpecificationAttributeOptionId)"); diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml index 1a55ffa37..517156815 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddRecommendedProductModel +@model ProductModel.AddRecommendedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Recommended.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]; } - @@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.Recommended.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]
@@ -33,13 +34,13 @@
- +
@@ -64,18 +65,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -128,8 +132,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -144,7 +151,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RecommendedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RecommendedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -176,7 +183,7 @@ scrollable: false, columns: [{ field: "Id", - title: "@Loc["Admin.Common.Check"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Check"]", headerTemplate: "", headerAttributes: { style: "text-align:center" }, template: "", @@ -184,10 +191,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -220,4 +227,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml index 27144de2d..78e26217d 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddRelatedProductModel +@model ProductModel.AddRelatedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.RelatedProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.RelatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -129,8 +133,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -142,12 +149,12 @@ var numChkBoxesChecked = $('#products-grid input[type=checkbox][id!=mastercheckbox]:checked').length; $('#mastercheckbox').prop('checked', numChkBoxes == numChkBoxesChecked && numChkBoxes > 0); } - + $("#products-grid").kendoGrid({ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RelatedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RelatedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -186,17 +193,17 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(Published) {# #} else {# #} #' }] }); - + $('#search-products').click(function () { var grid = $('#products-grid').data('kendoGrid'); grid.dataSource.page(1); //new search. Set page size to 1 @@ -221,6 +228,6 @@ $('#products-grid').on('change', 'input[type=checkbox][id!=mastercheckbox]', function(e) { updateMasterCheckbox(); }); - + - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RequiredProductAddPopup.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RequiredProductAddPopup.cshtml index 301c2f739..35a55dd26 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RequiredProductAddPopup.cshtml @@ -1,9 +1,10 @@ -@model ProductModel.AddRequiredProductModel +@model ProductModel.AddRequiredProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.Choose"]; }
@@ -12,7 +13,7 @@
- @Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.Choose"]
@@ -30,13 +31,13 @@
- +
@@ -61,18 +62,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -94,8 +98,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -120,7 +127,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RequiredProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RequiredProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -152,15 +159,15 @@ scrollable: false, columns: [{ field: "Name", - title: "@Loc["Admin.Common.Select"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Select"]", width: 50, - template: '' + template: '' },{ field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -183,4 +190,4 @@ }); -
\ No newline at end of file +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml index d26e5564a..8ae04c9da 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddSimilarProductModel +@model ProductModel.AddSimilarProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.SimilarProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]; } -
@@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.SimilarProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]
@@ -33,13 +34,13 @@
- +
@@ -64,18 +65,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -128,8 +132,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -146,7 +153,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("SimilarProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("SimilarProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -185,10 +192,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -221,4 +228,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceCreatePopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceCreatePopup.cshtml index f56e53cdc..0a022e837 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceCreatePopup.cshtml @@ -1,13 +1,14 @@ -@model ProductModel.TierPriceModel +@model ProductModel.TierPriceModel @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
@@ -18,7 +19,7 @@
- @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"]
@@ -58,4 +59,4 @@ }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml similarity index 84% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml index fc5ae7807..dcd4d740e 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml @@ -2,12 +2,13 @@ @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.TierPrices.Edit"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]; } -
@@ -18,7 +19,7 @@
- @Loc["Vendor.Catalog.Products.TierPrices.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml new file mode 100644 index 000000000..99532cee9 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml @@ -0,0 +1,28 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using System.Globalization +@using Microsoft.AspNetCore.Http.Extensions +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using System.Text +@using Grand.SharedKernel.Extensions +@using Grand.Infrastructure +@using Grand.Domain.Common +@using Grand.Domain.Catalog +@using Grand.Domain.Directory +@using Grand.Web.Common +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization +@using Grand.Web.AdminShared.Models.Catalog +@using Grand.Web.AdminShared.Interfaces + +@inject LocService Loc +@inject IEnumTranslationService EnumTranslationService +@inject IAdminDataScope Scope + +@* Two host-detection idioms in these shared views; do not invent a third. + Scope.ResourceKeyPrefix — "Admin" or "Vendor". Use for anything the same in Admin and Store but + different in Vendor: resource keys, and capability differences Vendor doesn't have. + ViewContext.RouteData.Values["area"] — "Admin", "Store" or "Vendor". Use ONLY when Admin must be + told apart from Store; both report ResourceKeyPrefix == "Admin", so it cannot distinguish them. + The same "area" route value drives RoutedProductDataScope, so the two cannot drift apart. *@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewStart.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewStart.cshtml new file mode 100644 index 000000000..cbfdf5cb1 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewStart.cshtml @@ -0,0 +1,8 @@ +@using Microsoft.AspNetCore.Routing +@{ + var area = Context.GetRouteValue("area")?.ToString(); + if (!string.IsNullOrEmpty(area)) + { + Layout = $"~/Areas/{area}/Views/Shared/_{area}Layout.cshtml"; + } +} diff --git a/src/Web/Grand.Web.Common/Grand.Web.Common.csproj b/src/Web/Grand.Web.Common/Grand.Web.Common.csproj index 607719f89..53d8c644a 100644 --- a/src/Web/Grand.Web.Common/Grand.Web.Common.csproj +++ b/src/Web/Grand.Web.Common/Grand.Web.Common.csproj @@ -1,5 +1,8 @@  + + + diff --git a/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs b/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs index 3fcada461..3c5660382 100644 --- a/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs +++ b/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs @@ -1,4 +1,6 @@ -using Grand.Web.Common.Themes; +using Grand.Web.Common.Themes; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; @@ -7,6 +9,13 @@ namespace Grand.Web.Common.View; public class ViewLocationExpander : IViewLocationExpander { private const string ThemeKey = "Theme"; + // Nested under /Views/AdminShared/ deliberately: a Razor view's compiled path is a global lookup + // key across every ApplicationPart, and the shallow /Views/{1}/{0}.cshtml form collided with the + // combined Grand.Web host's own storefront views (/Views/_ViewStart.cshtml, + // /Views/Product/Partials/ProductAttributes.cshtml). The AdminShared segment is unowned by any + // other project, so shared entity folders added in later phases cannot collide. + private const string AdminSharedFallbackLocation = "/Views/AdminShared/{1}/{0}.cshtml"; + private const string AdminSharedControllersNamespace = "Grand.Web.AdminShared.Controllers"; public void PopulateValues(ViewLocationExpanderContext context) { @@ -21,11 +30,28 @@ public void PopulateValues(ViewLocationExpanderContext context) public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable viewLocations) { - if (!context.Values.TryGetValue(ThemeKey, out var _)) return viewLocations; + if (context.Values.TryGetValue(ThemeKey, out _)) + { + var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); + viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + } - var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); - viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + if (IsAdminSharedController(context.ActionContext.ActionDescriptor)) + viewLocations = viewLocations.Append(AdminSharedFallbackLocation); return viewLocations; } -} \ No newline at end of file + + /// Whether the executing action's controller type (or any base type) lives in + /// Grand.Web.AdminShared.Controllers. Generic by design — no per-entity base-controller + /// list to maintain: the moment a future Base*Controller (Order, Category, ...) lands in + /// that namespace, its host subclasses get the AdminShared view fallback automatically. + internal static bool IsAdminSharedController(ActionDescriptor descriptor) + { + if (descriptor is not ControllerActionDescriptor cad) return false; + for (var t = cad.ControllerTypeInfo.AsType(); t is not null; t = t.BaseType) + if (t.Namespace == AdminSharedControllersNamespace) + return true; + return false; + } +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml deleted file mode 100644 index 22cf8fa00..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ /dev/null @@ -1,207 +0,0 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; -} - - -
-
- -
-
- - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml deleted file mode 100644 index fb283c0d9..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@model ProductModel.AddAssociatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"]; -} - -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml deleted file mode 100644 index 1a586280c..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml +++ /dev/null @@ -1,299 +0,0 @@ -@using System.Text.Encodings.Web -@model BulkEditListModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.BulkEdit"]; -} -
- -
-
- -
-
- - - - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml deleted file mode 100644 index 02395a9ff..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml +++ /dev/null @@ -1,206 +0,0 @@ -@model ProductModel.AddBundleProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.BundleProducts.AddNew"]; -} -
- -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml deleted file mode 100644 index 0ff5e79e4..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml +++ /dev/null @@ -1,37 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.AddNew"] - - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") - -
-
-
- - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml deleted file mode 100644 index 28a4c1a9b..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml +++ /dev/null @@ -1,214 +0,0 @@ -@model ProductModel.AddCrossSellProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.CrossSells.AddNew"]; -} -
- - -
-
- -
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Additional.cshtml deleted file mode 100644 index fe8689d29..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ /dev/null @@ -1,252 +0,0 @@ -@using Grand.Domain.Media -@model ProductModel -@{ - ViewData["DownloadType"] = DownloadType.Product; - ViewData["ReferenceId"] = Model.Id; -} - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - - - - @Loc["Admin.Catalog.Products.Fields.RequiredProductIds.AddNew"] - - - - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Bids.cshtml deleted file mode 100644 index f831e021f..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Bids.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - -
- -
-
-
- -
- -} -else -{ -
- @Loc["Admin.Catalog.Products.Bids.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml deleted file mode 100644 index 82728fbe1..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ /dev/null @@ -1,149 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.BundleProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml deleted file mode 100644 index d99b5a800..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ /dev/null @@ -1,376 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - -
-

@Loc["Admin.Catalog.Products.Calendar.Calendarconfiguration"]

-
-
-
- -
- -
-
- -
- -
-
- -
-
- -
- -
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- - - - -
-
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
-
@Loc["Admin.Catalog.Products.Calendar.WeekDay"]
-
-
-
-
- - - - - - - -
-
-
-
-
-
-
- -
-
-
-
- - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Calendar.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Categories.cshtml deleted file mode 100644 index 82c8ca020..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Categories.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Categories.Fields.Category"] -
-
-
-
- -
- -} -else -{ -
- @Loc["Admin.Catalog.Products.Categories.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml deleted file mode 100644 index 932bdf443..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Collections.Fields.Collection"] -
-
-
-
- -
- - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Collections.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml deleted file mode 100644 index c25a78ffb..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.CrossSells"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.CrossSells.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Info.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Info.cshtml index b2f0be591..b233213d1 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Info.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Info.cshtml @@ -485,7 +485,7 @@ } - +
@@ -739,4 +739,4 @@ }
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml deleted file mode 100644 index fc89e14d6..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ /dev/null @@ -1,278 +0,0 @@ -@model ProductModel - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- @if (Model.ProductWarehouseInventoryModels.Count > 0) - { - - - - - - - - - - - @for (var i = 0; i < Model.ProductWarehouseInventoryModels.Count; i++) - { - var item = Model.ProductWarehouseInventoryModels[i]; - - - - - - - } - -
- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] -
-
- @item.WarehouseName (@item.WarehouseCode) - -
-
- - - - - -
-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description1"] -

-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description2"] -

-

- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Description3"] -

- } - else - { - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] - } -
-
-
- -
- - - - - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- [@Model.BaseWeightIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 27d95349a..f52736434 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -1,6 +1,6 @@ @model ProductModel @inject AdminAreaSettings adminAreaSettings - +
@@ -383,4 +383,4 @@ else @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"])
} - \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml deleted file mode 100644 index 078c62814..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ /dev/null @@ -1,231 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -
-

- @Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Description"] -

-

- @Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Description2"] -

-
-
- -
-
-
- - -
- - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml deleted file mode 100644 index 1588b30f5..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ /dev/null @@ -1,273 +0,0 @@ -@model ProductModel - - - - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml deleted file mode 100644 index 44c495ef9..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.cshtml +++ /dev/null @@ -1,38 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ - if (Model.AvailableProductAttributes.Count > 0) - { - - - - -
- -
-
-
- - -
- -
-
-
- -
-
- } - else - { -
- @Loc["Admin.Catalog.Products.ProductAttributes.NoAttributesAvailable"] -
- } -} -else -{ -
- @Loc["Admin.Catalog.Products.ProductAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml deleted file mode 100644 index 3a2355db7..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ /dev/null @@ -1,147 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Directory -@model ProductModel -@inject ICurrencyService currencyService -@{ - var currencies = await currencyService.GetAllCurrencies(); - var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); - currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); -} -@if (!string.IsNullOrEmpty(Model.Id) && currencies.Count > 0) -{ - if (currencies.Count > 0) - { -
-
-
-
-
- - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml index 3b94ef6c1..395ca8f15 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml @@ -2,11 +2,11 @@ @inject AdminAreaSettings adminAreaSettings
- +
- +
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml deleted file mode 100644 index 509467fe0..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ /dev/null @@ -1,74 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml deleted file mode 100644 index 511a2fe09..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Admin.Catalog.Products.SimilarProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SimilarProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml deleted file mode 100644 index 21805ad8f..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ /dev/null @@ -1,159 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 51140f456..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,153 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model ProductModel -@inject IPermissionService permissionService -@{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - - - -
-
-
- - -
- -
-
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - -
- -
-
-
- - -
- -
-
-
- if (canManageDocuments) - { - - -
- -
-
-
- } - - - -
- -
-
-
- } - -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml deleted file mode 100644 index 628574efb..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ /dev/null @@ -1,69 +0,0 @@ -@model ProductModel.TierPriceModel - -
- - - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml deleted file mode 100644 index 09b543f61..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model IList -@if (Model.Count > 0) -{ -
- @foreach (var attribute in Model) - { - var controlId = $"attributes[{attribute.Id}]"; - var textPrompt = !string.IsNullOrEmpty(attribute.TextPrompt) ? attribute.TextPrompt : attribute.Name; -
-
- @if (attribute.IsRequired) - { - * - } - -
-
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.Checkboxes: - case AttributeControlType.ReadonlyCheckboxes: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.TextBox: - { - - } - break; - case AttributeControlType.MultilineTextbox: - { - - } - break; - case AttributeControlType.Datepicker: - { - - } - break; - case AttributeControlType.FileUpload: - { - - } - break; - } -
-
- } -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 000000000..e1f883eda --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 000000000..7cd271cad --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml new file mode 100644 index 000000000..bb689e612 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml new file mode 100644 index 000000000..85433d6c4 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml new file mode 100644 index 000000000..149d05748 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 000000000..aefdca3b0 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml new file mode 100644 index 000000000..3114450be --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml new file mode 100644 index 000000000..aedb71f2a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 000000000..b80791c34 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml new file mode 100644 index 000000000..d1350516a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml new file mode 100644 index 000000000..a8b6748b0 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml new file mode 100644 index 000000000..54c503a79 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml new file mode 100644 index 000000000..9402ef60e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml new file mode 100644 index 000000000..3d6de63e9 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml new file mode 100644 index 000000000..84b8f36fb --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml new file mode 100644 index 000000000..c6bcc6c94 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml new file mode 100644 index 000000000..21a42edbb --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml new file mode 100644 index 000000000..2492ba081 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml new file mode 100644 index 000000000..a111dcf55 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..f263528ba --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..a2c42fcee --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..2f4a52988 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..4775b161d --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..c938b048d --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml new file mode 100644 index 000000000..43d63cf65 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml new file mode 100644 index 000000000..3e3839d36 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml new file mode 100644 index 000000000..33c7b59fc --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml new file mode 100644 index 000000000..0322395d4 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml new file mode 100644 index 000000000..fb0c71d1e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml new file mode 100644 index 000000000..733f1af46 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml new file mode 100644 index 000000000..7158d2028 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml new file mode 100644 index 000000000..4da25a557 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 000000000..ec7386b2d --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 000000000..bbf9437b2 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 000000000..ac636cdac --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 000000000..2f4e681c7 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..22b489f3a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..358b75567 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 000000000..b99ee08a6 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 000000000..6cfa7bc6f --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..59e68b1e2 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 000000000..c310daa9e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 000000000..327d71310 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 000000000..830aa0ddf --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml deleted file mode 100644 index d64dad535..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml +++ /dev/null @@ -1,139 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Details"]; -} -
- - -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Admin.Catalog.Products.ProductAttributes.AddNew"] - } - else - { - @Loc["Admin.Catalog.Products.ProductAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml deleted file mode 100644 index b2a17d0c7..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ /dev/null @@ -1,111 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; - - var attributeControlType = Model.AttributeControlTypeId; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
- @if (!Model.ValidationRulesAllowed) - { -
This attribute type cannot have validation rules
- } - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml deleted file mode 100644 index 73afd065a..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ /dev/null @@ -1,59 +0,0 @@ -@model ProductModel.ProductAttributeValueModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml deleted file mode 100644 index c5ad07ff7..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; -} -@model ProductModel.ProductAttributeValueModel -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml deleted file mode 100644 index e2d9be7d1..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml +++ /dev/null @@ -1,152 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductPictureModel -@{ - Layout = ""; -} -
- - - - @{ - Func template = @
-
- -
- - -
-
-
- -
- - -
-
- -
; - } - -
- - - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.Pictures.Details"] -
-
-
-
-
-
- -
- - - -
-
- -
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml deleted file mode 100644 index 3d27fb777..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml +++ /dev/null @@ -1,201 +0,0 @@ -@model ProductModel.AddProductSpecificationAttributeModel -@{ - Layout = ""; -} -
- -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.AddNew"] - } - else - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml deleted file mode 100644 index 8cb2f5a15..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml +++ /dev/null @@ -1,209 +0,0 @@ -@model ProductModel.AddRecommendedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Recommended.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml deleted file mode 100644 index 0604e40e4..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml +++ /dev/null @@ -1,212 +0,0 @@ -@model ProductModel.AddRelatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.RelatedProducts.AddNew"]; -} -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml deleted file mode 100644 index dbcd9502e..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml +++ /dev/null @@ -1,172 +0,0 @@ -@model ProductModel.AddRequiredProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"]; -} - -
-
- -
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml deleted file mode 100644 index b58a4ff3c..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml +++ /dev/null @@ -1,210 +0,0 @@ -@model ProductModel.AddSimilarProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.SimilarProducts.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml deleted file mode 100644 index c27cac08c..000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.Edit"]; -} - -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.TierPrices.Edit"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 33c6b0631..bc24fa730 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -1,2773 +1,58 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; -using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.SharedKernel.Extensions; -using Grand.Web.AdminShared.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; -using NPOI.SS.Formula.Functions; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseStoreController +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Store's DI wiring, the EditWarningCheck +// hook, and the attributes that used to arrive transitively via BaseStoreController - +// BaseProductController can't inherit any single host's base controller (it's shared across +// Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), so each subclass restates its +// own host's attribute set explicitly. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope) { - #region Constructors - - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - IContextAccessor contextAccessor, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _contextAccessor = contextAccessor; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly IContextAccessor _contextAccessor; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion - - #region Methods - - /// - /// Whether the given product is accessible to this store's staff. Null-safe: a missing product - /// is treated the same as one belonging to another store. Mirrors Grand.Web.Vendor's - /// CheckAccessToProduct - callers decide how to respond (redirect, grid error, JSON error, ...), - /// this only answers the yes/no question. - /// - private bool CanAccessProduct(Product product) - { - return product != null && - product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - } - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var (productModels, totalCount) = await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) - { - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - } - - //not found - Warning(_translationService.GetResource("Admin.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel { - StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId - }; - await _productViewModelService.PrepareProductModel(model, null, true, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Admin.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, null, false, true); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!product.LimitedToStores || (product.LimitedToStores && - product.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && - product.Stores.Count > 1)) - { - Warning(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - } - else - { - if (!CanAccessProduct(product)) - return RedirectToAction("List"); - } - - var model = product.ToModel(_dateTimeService); - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, product, false, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Admin.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - product = await _productViewModelService.UpdateProductModel(product, model); - Success(_translationService.GetResource("Admin.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, product, false, true); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Admin.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - - if (originalProduct.LimitedToStores && !originalProduct.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - - originalProduct.LimitedToStores = true; - originalProduct.Stores.Clear(); - originalProduct.Stores.Add(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - - var newProduct = await copyProductService.CopyProduct(originalProduct, copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var ids = new List(); - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - foreach (var str1 in rangeArray) ids.Add(str1); - - var products = await _productService.GetProductsByIds(ids.ToArray(), true); - for (var i = 0; i <= products.Count - 1; i++) - { - if (!CanAccessProduct(products[i])) - continue; - - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRelatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareSimilarProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductBundleId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductBundleId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareBundleProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareCrossSellProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRecommendedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - if (!CanAccessProduct(associatedProduct)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - var parentProduct = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(parentProduct)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - //InsertAssociatedProductModel reparents each selected product (writes ParentGroupedProductId on it), - //so every selected id must also belong to the current store, not just the parent. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await _productService.GetProductById(id); - if (CanAccessProduct(selected)) - validIds.Add(id); - } - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Any()) await _productViewModelService.InsertAssociatedProductModel(model); - } - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new - { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new - { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new - { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - if (!CanAccessProduct(product)) - return Json(new - { - success = false, - message = "Access denied - staff permissions" - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = file.FileName; - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = (await specificationAttributeService.GetSpecificationAttributeById(attributeId)).SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productrSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = new OrderListModel { - ProductId = productId - }; - - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", storeId, productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - var validProducts = await FilterValidProductsForStore(products); - - if (validProducts.Any()) - await _productViewModelService.UpdateBulkEdit(validProducts); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - var validProducts = await FilterValidProductsForStore(products); - - if (validProducts.Any()) - await _productViewModelService.DeleteBulkEdit(validProducts.ToList()); - - return new JsonResult(""); - } - - - /// - /// Filters product models to include only valid products accessible to the current store - /// - /// Collection of product models to filter - /// List of product models that are accessible to the current store - private async Task> FilterValidProductsForStore(IEnumerable products) - { - var validProducts = new List(); - - foreach (var pModel in products) - { - if (string.IsNullOrEmpty(pModel.Id)) - continue; - - var product = await _productService.GetProductById(pModel.Id); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - continue; - - validProducts.Add(pModel); - } - - return validProducts; - } - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return RedirectToAction("List", "Product"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return RedirectToAction("List", "Product"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (!CanAccessProduct(product)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - if (!CanAccessProduct(associatedProduct)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string Id) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, Id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var warnings = await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, command.Page - 1, - command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var reservations = await _productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new - { - errors = _translationService.GetResource("Admin.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = 0; - switch ((IntervalUnit)model.IntervalUnit) - { - case IntervalUnit.Minute: - minutesToAdd = model.Interval; - break; - case IntervalUnit.Hour: - minutesToAdd = model.Interval * 60; - break; - case IntervalUnit.Day: - minutesToAdd = model.Interval * 60 * 24; - break; - } - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file + // Re-derived from the original Store ProductController.Edit(GET) (pre-Task-11: + // src/Web/Grand.Web.Store/Controllers/ProductController.cs:184-189), not retyped from memory - the + // condition is unusual (warns when NOT limited to stores at all, or when limited AND the staff + // member's store is one of several) and easy to get backwards. Scope.DefaultStoreId is exactly + // StaffStoreId for Store (StoreAdminDataScope.DefaultStoreId => CurrentCustomer.StaffStoreId), so it + // stands in for the original's direct IContextAccessor access without reintroducing that dependency, + // which Tasks 7/8 deliberately eliminated from this call path. + protected override void EditWarningCheck(Product product) + { + if (!product.LimitedToStores || + (product.LimitedToStores && + product.Stores.Contains(Scope.DefaultStoreId) && + product.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Catalog.Products.Permissions")); + } +} diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml deleted file mode 100644 index 4b11a5dad..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ /dev/null @@ -1,207 +0,0 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; -} - -
-
-
- -
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml deleted file mode 100644 index d32d7fcab..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@model ProductModel.AddAssociatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.AssociatedProducts.AddNew"]; -} - -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml deleted file mode 100644 index 307256c14..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml +++ /dev/null @@ -1,299 +0,0 @@ -@using System.Text.Encodings.Web -@model BulkEditListModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.BulkEdit"]; -} -
- -
-
- -
-
- - - - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml deleted file mode 100644 index 24e8c7d5c..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml +++ /dev/null @@ -1,208 +0,0 @@ -@model ProductModel.AddBundleProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.BundleProducts.AddNew"]; -} -
- -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml deleted file mode 100644 index d5f41e079..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml +++ /dev/null @@ -1,37 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.AddNew"] - - @Html.ActionLink(Loc["Vendor.Catalog.Products.BackToList"], "List") - -
-
-
- - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml deleted file mode 100644 index 373e62eb2..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml +++ /dev/null @@ -1,214 +0,0 @@ -@model ProductModel.AddCrossSellProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.CrossSells.AddNew"]; -} -
- - -
-
- -
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml deleted file mode 100644 index c53b8eb7c..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml +++ /dev/null @@ -1,100 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.EditProductDetails"]; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.EditProductDetails"] - @Model.Name - - @Html.ActionLink(Loc["Vendor.Catalog.Products.BackToList"], "List") - -
-
-
- - - - - - @Loc["Vendor.Common.Delete"] - - -
-
-
-
- -
-
-
-
-
- - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Additional.Downloads.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Additional.Downloads.cshtml new file mode 100644 index 000000000..850a1eba6 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Additional.Downloads.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor products can't be downloads - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.FeaturedColumn.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.FeaturedColumn.cshtml new file mode 100644 index 000000000..09f8b528b --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.FeaturedColumn.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor has no IsFeaturedProduct column - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.LinkTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.LinkTemplate.cshtml new file mode 100644 index 000000000..afa4c2a46 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Categories.LinkTemplate.cshtml @@ -0,0 +1,2 @@ +@* Vendor can't open the Category edit page - see ARCH-001 Phase 2 spec section 6. *@ +template: '#:Category#' diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.FeaturedColumn.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.FeaturedColumn.cshtml new file mode 100644 index 000000000..09f8b528b --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.FeaturedColumn.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor has no IsFeaturedProduct column - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.LinkTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.LinkTemplate.cshtml new file mode 100644 index 000000000..343b623e7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Collections.LinkTemplate.cshtml @@ -0,0 +1,2 @@ +@* Vendor can't open the Collection edit page - see ARCH-001 Phase 2 spec section 6. *@ +template: '#:Collection#' diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml deleted file mode 100644 index 36176b7d8..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-

@Loc["Vendor.Catalog.Products.AssociatedProducts.Note2"]

-
- - - - -} -else -{ - @Loc["Vendor.Catalog.Products.AssociatedProducts.SaveBeforeEdit"] -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml deleted file mode 100644 index ea70f54d1..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ /dev/null @@ -1,165 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- -
- - -

- @Loc["Vendor.Catalog.Products.Pictures.AddNew"] -

-
-
-
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaVendor }); - ViewData["Click"] = "btnRefreshProductPictures"; - } -
- - -
- - -
-
-
-} -else -{ -
- @Loc["Vendor.Catalog.Products.Pictures.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml deleted file mode 100644 index 0725feba0..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ /dev/null @@ -1,231 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -
-

- @Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Description"] -

-

- @Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Description2"] -

-
-
- -
-
-
- - -
- - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml deleted file mode 100644 index b22e91491..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ /dev/null @@ -1,144 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Directory -@model ProductModel -@inject ICurrencyService currencyService -@{ - var currencies = await currencyService.GetAllCurrencies(); - var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); - currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); -} -@if (!string.IsNullOrEmpty(Model.Id) && currencies.Any()) -{ -
-
-
-
-
- -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml deleted file mode 100644 index 1cc94c671..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Vendor.Catalog.Products.Recommended"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.Recommended.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml deleted file mode 100644 index d53a659b2..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Vendor.Catalog.Products.RelatedProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.RelatedProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml deleted file mode 100644 index 213f234ee..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ /dev/null @@ -1,74 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml deleted file mode 100644 index 72fc8f6e5..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductAttributeValueModel - -
- - - - - - - - -@{ - Func - template = @
-
- -
- - -
-
- -
; -} - - -
- - - -
-
- -
- - -
-
-
-
- -
- @if (Model.DisplayColorSquaresRgb) - { -
- -
- -
- - -
-
- } - @if (Model.DisplayImageSquaresPicture) - { -
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.ProductId; - } - -
- - -
-
- } -
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
    -
  • -

    - checked="checked" - }> -

    -

    - -

    -
  • - @foreach (var picture in Model.ProductPictureModels) - { -
  • -

    - checked="checked" - }> -

    -

    - -

    -
  • - } -
- -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml deleted file mode 100644 index 09b543f61..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model IList -@if (Model.Count > 0) -{ -
- @foreach (var attribute in Model) - { - var controlId = $"attributes[{attribute.Id}]"; - var textPrompt = !string.IsNullOrEmpty(attribute.TextPrompt) ? attribute.TextPrompt : attribute.Name; -
-
- @if (attribute.IsRequired) - { - * - } - -
-
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.Checkboxes: - case AttributeControlType.ReadonlyCheckboxes: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.TextBox: - { - - } - break; - case AttributeControlType.MultilineTextbox: - { - - } - break; - case AttributeControlType.Datepicker: - { - - } - break; - case AttributeControlType.FileUpload: - { - - } - break; - } -
-
- } -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.CustomerLinkTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.CustomerLinkTemplate.cshtml new file mode 100644 index 000000000..a1b464903 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.CustomerLinkTemplate.cshtml @@ -0,0 +1,2 @@ +@* Vendor can't open the Customer edit page - see ARCH-001 Phase 2 spec section 6. *@ +template: '#:CustomerInfo#', diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.TitleLinkTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.TitleLinkTemplate.cshtml new file mode 100644 index 000000000..fd05e4bc4 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Reviews.TitleLinkTemplate.cshtml @@ -0,0 +1,2 @@ +@* Vendor can't open the ProductReview edit page - see ARCH-001 Phase 2 spec section 6. *@ +template: '#:Title#', diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.Documents.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.Documents.cshtml new file mode 100644 index 000000000..2804b78e6 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.Documents.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor never shows a Documents tab - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.UserFields.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.UserFields.cshtml new file mode 100644 index 000000000..df0530588 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/Tab.UserFields.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor never shows a User fields tab - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml new file mode 100644 index 000000000..830d71369 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml @@ -0,0 +1 @@ +@* Intentionally empty: Vendor isn't store-scoped, so tier prices have no Store/CustomerGroup fields - see ARCH-001 Phase 2 spec section 6. *@ diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 000000000..b8cd9db14 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 000000000..a5df149d2 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml new file mode 100644 index 000000000..f5404790a --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml new file mode 100644 index 000000000..094557a58 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml new file mode 100644 index 000000000..f5192564a --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 000000000..4d75e5543 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml new file mode 100644 index 000000000..8287bcaf7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml new file mode 100644 index 000000000..9255dd56b --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 000000000..0cb4f7294 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml new file mode 100644 index 000000000..f64bc21e5 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml new file mode 100644 index 000000000..aa746a2e4 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml new file mode 100644 index 000000000..8e7de1cdf --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml new file mode 100644 index 000000000..84512ca1a --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml new file mode 100644 index 000000000..035468c5f --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml new file mode 100644 index 000000000..684cdabf7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml new file mode 100644 index 000000000..f79d97656 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml new file mode 100644 index 000000000..b4f586f49 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml new file mode 100644 index 000000000..53287ec62 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml new file mode 100644 index 000000000..e914e04d8 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..89feb65ec --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml new file mode 100644 index 000000000..3dad792bf --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml new file mode 100644 index 000000000..09171dbf9 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml new file mode 100644 index 000000000..1a3099be9 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml new file mode 100644 index 000000000..12f0c1089 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml new file mode 100644 index 000000000..db50a5377 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml new file mode 100644 index 000000000..7cc9aa048 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml new file mode 100644 index 000000000..4b5b926d0 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml new file mode 100644 index 000000000..bb847e142 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 000000000..911bed67f --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 000000000..182693bec --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 000000000..5704aea05 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 000000000..e75d6cdf3 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..28fa53d0b --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..4703a53cd --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 000000000..f612fa5a4 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 000000000..7669a0c52 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..ce8a26fea --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 000000000..c853bd4c1 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 000000000..f79cb9d5a --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 000000000..01d166d13 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml deleted file mode 100644 index 2b02275dd..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml +++ /dev/null @@ -1,200 +0,0 @@ -@model ProductAttributeConditionModel -@{ - Layout = ""; - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition"]; -} -
- - -
- - -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition"] -
-
-
- @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] -
-
-
-
-
- -
- - -
-
-
- @if (Model.ProductAttributes.Count > 0) - { - var attributesList = Model.ProductAttributes.Select(attribute => new SelectListItem { Text = attribute.Name, Value = attribute.Id }).ToList(); -
-
-
- -
- - -
-
-
- -
- @foreach (var attribute in Model.ProductAttributes) - { - var controlId = $"attributes[{attribute.Id}]"; -
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.Checkboxes: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - break; - } -
- } -
-
-
-
- } - else - { -
No attribute exists that could be used as condition
- } -
-
-
-
- -
-
-
- -
-
-
-
-
-
- - - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml deleted file mode 100644 index e6b7d0f7f..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml +++ /dev/null @@ -1,139 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Details"]; -} -
- - -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Vendor.Catalog.Products.ProductAttributes.AddNew"] - } - else - { - @Loc["Vendor.Catalog.Products.ProductAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml deleted file mode 100644 index de5e48cf6..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ /dev/null @@ -1,111 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; - - var attributeControlType = Model.AttributeControlTypeId; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
- @if (!Model.ValidationRulesAllowed) - { -
This attribute type cannot have validation rules
- } - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml deleted file mode 100644 index ce676d3c6..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; -} -@model ProductModel.ProductAttributeValueModel -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml deleted file mode 100644 index 7b210cbc2..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml +++ /dev/null @@ -1,152 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductPictureModel -@{ - Layout = ""; -} -
- - - - @{ - Func template = @
-
- -
- - -
-
-
- -
- - -
-
- -
; - } - -
- - - - -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.Pictures.Details"] -
-
-
-
-
-
- -
- - - -
-
- -
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml deleted file mode 100644 index e84e7bfde..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml +++ /dev/null @@ -1,209 +0,0 @@ -@model ProductModel.AddRecommendedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.Recommended.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml deleted file mode 100644 index d5d1a39c6..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml +++ /dev/null @@ -1,212 +0,0 @@ -@model ProductModel.AddRelatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.RelatedProducts.AddNew"]; -} -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml deleted file mode 100644 index 0dc56e055..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml +++ /dev/null @@ -1,172 +0,0 @@ -@model ProductModel.AddRequiredProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.Fields.RequiredProductIds.Choose"]; -} - -
-
- -
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml deleted file mode 100644 index b0a8f278b..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml +++ /dev/null @@ -1,210 +0,0 @@ -@model ProductModel.AddSimilarProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.SimilarProducts.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml deleted file mode 100644 index 5d87d3aac..000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.TierPrices.AddNew"]; -} - -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml index 38ef91d93..83c4d41cb 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml @@ -26,7 +26,10 @@ @using Grand.Web.Common.Localization @using Grand.Web.Vendor.Extensions; @using Grand.Web.Vendor.Models.Common; -@using Grand.Web.Vendor.Models.Catalog; +@* Product views bind to Grand.Web.AdminShared's ProductModel family (ARCH-001 Phase 1 Task 12) - + Grand.Web.Vendor.Models.Catalog's own ProductModel etc. are no longer used by any controller/view + and importing both would make bare "ProductModel" ambiguous. *@ +@using Grand.Web.AdminShared.Models.Catalog; @using Grand.Web.Vendor.Models.Orders; @using Grand.Web.Vendor.Models.Shipment; @using Grand.Web.Vendor.Models.MerchandiseReturn; diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 17eb92f43..dc6ba4691 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -1,2588 +1,73 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Permissions; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; using Grand.Infrastructure; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Grand.Web.Vendor.Models.Orders; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; -using Grand.Web.Common.Helpers; -using Grand.SharedKernel.Extensions; namespace Grand.Web.Vendor.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseVendorController +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Vendor's DI wiring, the attributes that +// used to arrive transitively via BaseVendorController, and the 6 vendor-specific hooks +// BaseProductController's own doc comments explicitly assign to "a future Vendor subclass" once hosts +// are subclassed onto it (this task). BaseProductController can't inherit any single host's base +// controller (it's shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), +// so each subclass restates its own host's attribute set explicitly. No EditWarningCheck override +// needed - Vendor's original had no equivalent branch. +// +// DI wiring: resolves via AdminShared's own IStartupApplication (Priority 101), which registers +// IProductViewModelService for any host referencing the AdminShared assembly - including Vendor. +// Vendor's own duplicate registration was removed in Task 12, alongside the duplicate service/interface +// it pointed at. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope, + IContextAccessor contextAccessor) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope) { - #region Constructors + // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only + // sees the subset of a grouped product's associated products that they themselves own. Overriding + // the base's empty default, per BaseProductController.AssociatedProductVendorId's own doc comment. + protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - IContextAccessor contextAccessor, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _contextAccessor = contextAccessor; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #endregion + protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #region Fields + protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly IContextAccessor _contextAccessor; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; + protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #endregion - - #region Methods - - private Task<(bool allow, string message)> CheckAccessToProduct(Product product) - { - if (product == null) return Task.FromResult((false, "Product not exists")); - - //a vendor should have access only to his products - return !_contextAccessor.WorkContext.HasAccessToProduct(product) - ? Task.FromResult((false, "This is not your product")) - : Task.FromResult<(bool allow, string message)>((true, null)); - } - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - var (productModels, totalCount) = - await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) return RedirectToAction("Edit", "Product", new { id = product.Id }); - - //not found - Warning(_translationService.GetResource("Vendor.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel(); - await _productViewModelService.PrepareProductModel(model, null, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Vendor.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, null, false); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - var model = product.ToModel(_dateTimeService); - //model.Ticks = product.UpdatedOnUtc.Ticks; - - await _productViewModelService.PrepareProductModel(model, product, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Vendor.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - product = await _productViewModelService.UpdateProductModel(product, model); - - Success(_translationService.GetResource("Vendor.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, product, false); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Vendor.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteSelected(ICollection selectedIds) - { - if (selectedIds != null) await _productViewModelService.DeleteSelected(selectedIds.ToList()); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - //a vendor should have access only to his products - if (originalProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(originalProduct)) - return RedirectToAction("List"); - - var newProduct = await copyProductService.CopyProduct(originalProduct, - copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - var products = await _productService.GetProductsByIds(Enumerable.ToArray(rangeArray), true); - for (var i = 0; i <= products.Count - 1; i++) - { - if (!_contextAccessor.WorkContext.HasAccessToProduct(products[i])) continue; - - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(); - ViewBag.productIdsInput = productIdsInput; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var vendorId = ""; - if (_contextAccessor.WorkContext.CurrentVendor != null) vendorId = _contextAccessor.WorkContext.CurrentVendor.Id; - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - vendorId: vendorId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(associatedProduct)) - throw new ArgumentException("No associated product found with the specified id"); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No associated product found with the specified id"); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertAssociatedProductModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Json(new { - success = false, - message = "Access denied - vendor permissions" - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = Path.GetFileName(file.FileName); - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (SharedKernel.Extensions.FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, - reference: reference, objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productSpecsModel, - Total = productSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - return (await specificationAttributeService.GetSpecificationAttributes()) - .Select(sa => new SelectListItem { Text = sa.Name, Value = sa.Id }).ToList(); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var model = new OrderListModel { - ProductId = productId - }; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", "", productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelAll(ProductListModel model, - [FromServices] IExportManager exportManager) - { - var products = await _productViewModelService.PrepareProducts(model); - try - { - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelSelected(string selectedIds, - [FromServices] IExportManager exportManager) - { - var products = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - products.AddRange(await _productService.GetProductsByIds(ids, true)); - } - - //a vendor should have access only to his products - products = products.Where(p => _contextAccessor.WorkContext.HasAccessToProduct(p)).ToList(); - - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - if (products != null) await _productViewModelService.UpdateBulkEdit(products.ToList()); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - if (products != null) await _productViewModelService.DeleteBulkEdit(products.ToList()); - - return new JsonResult(""); - } - - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = productId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = productId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, model.ProductId); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Content("This is not your product"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Content("This is not your product"); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, - model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, - model); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, - productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - return Content(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(associatedProduct)) - return Content("Cannot load a product"); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - var warnings = - await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(string productId, - string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert( - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - var combination = - product.ProductAttributeCombinations.FirstOrDefault( - x => x.Id == model.ProductAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, - combination, model); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate( - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.ProductAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, - command.Page - 1, command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(model.ProductId, null, null); - if (reservations.Any()) - if ((product.IntervalUnitId is IntervalUnit.Minute or IntervalUnit.Hour && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new { - errors = _translationService.GetResource( - "Vendor.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = - (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { - IntervalUnit.Minute => model.Interval, - IntervalUnit.Hour => model.Interval * 60, - IntervalUnit.Day => model.Interval * 60 * 24, - _ => 0 - }; - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, - model.StartDate.Value.Day, 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, - model.EndDate.Value.Day, 23, 59, 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = model.ProductId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch - { - // ignored - } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource( - "Vendor.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult - { Errors = _translationService.GetResource("Vendor.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file + protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} diff --git a/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs b/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs deleted file mode 100644 index 1b9f70725..000000000 --- a/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Mapper; -using Grand.Web.Common.Extensions; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Extensions; - -public static class ProductsMappingExtensions -{ - public static ProductModel ToModel(this Product entity, IDateTimeService dateTimeService) - { - var product = entity.MapTo(); - product.MarkAsNewStartDateTime = entity.MarkAsNewStartDateTimeUtc.ConvertToUserTime(dateTimeService); - product.MarkAsNewEndDateTime = entity.MarkAsNewEndDateTimeUtc.ConvertToUserTime(dateTimeService); - product.AvailableStartDateTime = entity.AvailableStartDateTimeUtc.ConvertToUserTime(dateTimeService); - product.AvailableEndDateTime = entity.AvailableEndDateTimeUtc.ConvertToUserTime(dateTimeService); - product.PreOrderDateTime = entity.PreOrderDateTimeUtc.ConvertToUserTime(dateTimeService); - return product; - } - - public static Product ToEntity(this ProductModel model, IDateTimeService dateTimeService) - { - var product = model.MapTo(); - product.MarkAsNewStartDateTimeUtc = model.MarkAsNewStartDateTime.ConvertToUtcTime(dateTimeService); - product.MarkAsNewEndDateTimeUtc = model.MarkAsNewEndDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableStartDateTimeUtc = model.AvailableStartDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableEndDateTimeUtc = model.AvailableEndDateTime.ConvertToUtcTime(dateTimeService); - product.PreOrderDateTimeUtc = model.PreOrderDateTime.ConvertToUtcTime(dateTimeService); - - return product; - } - - public static Product ToEntity(this ProductModel model, Product destination, IDateTimeService dateTimeService) - { - var product = model.MapTo(destination); - product.MarkAsNewStartDateTimeUtc = model.MarkAsNewStartDateTime.ConvertToUtcTime(dateTimeService); - product.MarkAsNewEndDateTimeUtc = model.MarkAsNewEndDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableStartDateTimeUtc = model.AvailableStartDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableEndDateTimeUtc = model.AvailableEndDateTime.ConvertToUtcTime(dateTimeService); - product.PreOrderDateTimeUtc = model.PreOrderDateTime.ConvertToUtcTime(dateTimeService); - return product; - } - - public static ProductModel.ProductAttributeMappingModel ToModel(this ProductAttributeMapping entity) - { - return entity.MapTo(); - } - - public static ProductAttributeMapping ToEntity(this ProductModel.ProductAttributeMappingModel model) - { - return model.MapTo(); - } - - public static ProductAttributeMapping ToEntity(this ProductModel.ProductAttributeMappingModel model, - ProductAttributeMapping destination) - { - return model.MapTo(destination); - } - - public static ProductAttributeCombinationModel ToModel(this ProductAttributeCombination entity) - { - return entity.MapTo(); - } - - public static ProductModel.AddProductSpecificationAttributeModel ToModel(this ProductSpecificationAttribute entity) - { - return entity.MapTo(); - } - - public static ProductSpecificationAttribute ToEntity(this ProductModel.AddProductSpecificationAttributeModel model) - { - return model.MapTo(); - } - - public static ProductSpecificationAttribute ToEntity(this ProductModel.AddProductSpecificationAttributeModel model, - ProductSpecificationAttribute destination) - { - if (model.AttributeTypeId != SpecificationAttributeType.Option) - { - model.SpecificationAttributeId = ""; - model.SpecificationAttributeOptionId = ""; - model.AllowFiltering = false; - } - - return model.MapTo(destination); - } - - public static ProductAttributeValue ToEntity(this PredefinedProductAttributeValue model) - { - return model.MapTo(); - } - - public static ProductModel.TierPriceModel ToModel(this TierPrice entity, IDateTimeService dateTimeService) - { - var tierprice = entity.MapTo(); - tierprice.StartDateTime = entity.StartDateTimeUtc.ConvertToUserTime(dateTimeService); - tierprice.EndDateTime = entity.EndDateTimeUtc.ConvertToUserTime(dateTimeService); - return tierprice; - } - - public static TierPrice ToEntity(this ProductModel.TierPriceModel model, IDateTimeService dateTimeService) - { - var tierprice = model.MapTo(); - tierprice.StartDateTimeUtc = model.StartDateTime.ConvertToUtcTime(dateTimeService); - tierprice.EndDateTimeUtc = model.EndDateTime.ConvertToUtcTime(dateTimeService); - return tierprice; - } - - public static TierPrice ToEntity(this ProductModel.TierPriceModel model, TierPrice destination, - IDateTimeService dateTimeService) - { - var tierprice = model.MapTo(destination); - tierprice.StartDateTimeUtc = model.StartDateTime.ConvertToUtcTime(dateTimeService); - tierprice.EndDateTimeUtc = model.EndDateTime.ConvertToUtcTime(dateTimeService); - return tierprice; - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs deleted file mode 100644 index e73029d98..000000000 --- a/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Domain.Media; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Interfaces; - -public interface IProductViewModelService -{ - Task PrepareProductModel(ProductModel model, Product product, bool setPredefinedValues); - - Task PrepareProductReviewModel(ProductReviewModel model, ProductReview productReview, bool excludeProperties, - bool formatReviewText); - - Task OutOfStockNotifications(Product product, int prevStockQuantity, - List prevMultiWarehouseStock); - - Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination); - - Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); - Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareTierPriceModel(ProductModel.TierPriceModel model); - Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); - Task PrepareProductListModel(); - - Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel(ProductListModel model, - int pageIndex, int pageSize); - - Task> PrepareProducts(ProductListModel model); - Task InsertProductModel(ProductModel model); - Task UpdateProductModel(Product product, ProductModel model); - Task DeleteProduct(Product product); - Task DeleteSelected(IEnumerable selectedIds); - Task PrepareAddRequiredProductModel(); - - Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, - int pageIndex, int pageSize); - - Task> PrepareProductCategoryModel(Product product); - Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model); - Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model); - Task DeleteProductCategory(string id, string productId); - Task> PrepareProductCollectionModel(Product product); - Task InsertProductCollection(ProductModel.ProductCollectionModel model); - Task UpdateProductCollection(ProductModel.ProductCollectionModel model); - Task DeleteProductCollection(string id, string productId); - Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model); - Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model); - Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model); - Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model); - Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model); - Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model); - Task InsertBundleProductModel(ProductModel.AddBundleProductModel model); - Task UpdateBundleProductModel(ProductModel.BundleProductModel model); - Task DeleteBundleProductModel(ProductModel.BundleProductModel model); - Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model); - Task DeleteCrossSellProduct(string productId, string crossSellProductId); - Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model); - Task DeleteRecommendedProduct(string productId, string recommendedProductId); - Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); - Task DeleteAssociatedProduct(Product product); - Task PrepareRelatedProductModel(); - Task PrepareSimilarProductModel(); - Task PrepareBundleProductModel(); - Task PrepareCrossSellProductModel(); - Task PrepareRecommendedProductModel(); - Task PrepareAssociatedProductModel(); - Task PrepareBulkEditListModel(); - - Task<(IEnumerable bulkEditProductModels, int totalCount)> PrepareBulkEditProductModel( - BulkEditListModel model, int pageIndex, int pageSize); - - Task UpdateBulkEdit(IEnumerable products); - - Task DeleteBulkEdit(IEnumerable products); - - //tier-prices - Task> PrepareTierPriceModel(Product product); - - Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, - int pageSize); - - Task PrepareProductAttributeMappingModel(Product product); - - Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model); - - Task> PrepareProductAttributeMappingModels(Product product); - Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeValidationRulesModel(ProductAttributeMapping productAttributeMapping, - ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMapping productAttributeMapping, - ProductAttributeConditionModel model); - - Task PrepareProductAttributeValueModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task> PrepareProductAttributeValueModels(Product product, - ProductAttributeMapping productAttributeMapping); - - Task PrepareProductAttributeValueModel(ProductAttributeMapping pa, - ProductAttributeValue pav); - - Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model); - Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); - - Task - PrepareAssociateProductToAttributeValueModel(); - - Task> PrepareProductAttributeCombinationModel(Product product); - - Task PrepareProductAttributeCombinationModel(Product product, - string combinationId); - - Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model); - - Task GenerateAllAttributeCombinations(Product product); - - Task ClearAllAttributeCombinations(Product product); - - Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId); - - Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice); - - //Pictures - Task> PrepareProductPicturesModel(Product product); - - Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel(Product product, - ProductPicture productPicture); - - Task InsertProductPicture(Product product, Picture picture, int displayOrder); - Task UpdateProductPicture(ProductModel.ProductPictureModel model); - Task DeleteProductPicture(ProductModel.ProductPictureModel model); - - //Product specification - Task> PrepareProductSpecificationAttributeModel(Product product); - - Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, - Product product); - - Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model); - - Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs b/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs deleted file mode 100644 index f83ddd89a..000000000 --- a/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Grand.Mapping; -using Grand.Business.Core.Extensions; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Mapper; -using Grand.Web.Common.Extensions; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Mapper; - -public class ProductProfile : Profile, IAutoMapperProfile -{ - public ProductProfile() - { - CreateMap() - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.ProductTypeName, mo => mo.Ignore()) - .ForMember(dest => dest.AssociatedToProductId, mo => mo.Ignore()) - .ForMember(dest => dest.AssociatedToProductName, mo => mo.Ignore()) - .ForMember(dest => dest.StockQuantityStr, mo => mo.Ignore()) - .ForMember(dest => dest.CreatedOn, mo => mo.Ignore()) - .ForMember(dest => dest.UpdatedOn, mo => mo.Ignore()) - .ForMember(dest => dest.PictureThumbnailUrl, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableProductLayouts, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableProductAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.AddPictureModel, mo => mo.Ignore()) - .ForMember(dest => dest.ProductPictureModels, mo => mo.Ignore()) - .ForMember(dest => dest.CopyProductModel, mo => mo.Ignore()) - .ForMember(dest => dest.ProductWarehouseInventoryModels, mo => mo.Ignore()) - .ForMember(dest => dest.SeName, mo => mo.MapFrom(src => src.GetSeName("", true))) - .ForMember(dest => dest.AvailableTaxCategories, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableUnits, mo => mo.Ignore()) - .ForMember(dest => dest.PrimaryStoreCurrencyCode, mo => mo.Ignore()) - .ForMember(dest => dest.BaseDimensionIn, mo => mo.Ignore()) - .ForMember(dest => dest.BaseWeightIn, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableDeliveryDates, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableWarehouses, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableBasepriceUnits, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableBasepriceBaseUnits, mo => mo.Ignore()) - .ForPath(dest => dest.CalendarModel.IncBothDate, mo => mo.MapFrom(x => x.IncBothDate)); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()) - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.VendorId, mo => mo.Ignore()) - .ForMember(dest => dest.UpdatedOnUtc, mo => mo.Ignore()) - .ForMember(dest => dest.Coordinates, mo => mo.Ignore()) - .ForMember(dest => dest.ParentGroupedProductId, mo => mo.Ignore()) - .ForMember(dest => dest.ApprovedRatingSum, mo => mo.Ignore()) - .ForMember(dest => dest.NotApprovedRatingSum, mo => mo.Ignore()) - .ForMember(dest => dest.ApprovedTotalReviews, mo => mo.Ignore()) - .ForMember(dest => dest.NotApprovedTotalReviews, mo => mo.Ignore()) - .ForMember(dest => dest.ProductCategories, mo => mo.Ignore()) - .ForMember(dest => dest.ProductCollections, mo => mo.Ignore()) - .ForMember(dest => dest.ProductPictures, mo => mo.Ignore()) - .ForMember(dest => dest.ProductSpecificationAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.ProductWarehouseInventory, mo => mo.Ignore()) - .ForMember(dest => dest.Interval, mo => mo.Ignore()) - .ForMember(dest => dest.ProductAttributeMappings, mo => mo.Ignore()) - .ForMember(dest => dest.ProductAttributeCombinations, mo => mo.Ignore()) - .ForMember(dest => dest.TierPrices, mo => mo.Ignore()) - .ForMember(dest => dest.AppliedDiscounts, mo => mo.Ignore()) - .ForPath(dest => dest.IncBothDate, mo => mo.MapFrom(x => x.CalendarModel.IncBothDate)); - - CreateMap(); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.UseMultipleWarehouses, mo => mo.Ignore()) - .ForMember(dest => dest.PrimaryStoreCurrencyCode, mo => mo.Ignore()) - .ForMember(dest => dest.WarehouseInventoryModels, mo => mo.Ignore()); - CreateMap() - .ForMember(dest => dest.WarehouseInventory, mo => mo.Ignore()) - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.PriceAdjustmentStr, mo => mo.MapFrom(x => x.PriceAdjustment.ToString("N2"))) - .ForMember(dest => dest.WeightAdjustmentStr, mo => mo.MapFrom(x => x.WeightAdjustment.ToString("N2"))); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()) - .ForMember(dest => dest.Locales, mo => mo.MapFrom(x => x.Locales.ToTranslationProperty())); - - CreateMap() - .ForMember(dest => dest.AvailableAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableOptions, mo => mo.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(dest => dest.AvailableCurrencies, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - } - - public int Order => 0; -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs deleted file mode 100644 index 5dc967f13..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class BulkEditListModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchProductName")] - public string SearchProductName { get; set; } - - [UIHint("Category")] - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchCategory")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [UIHint("Collection")] - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchCollection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - public IList AvailableProductTypes { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs deleted file mode 100644 index 19d5a5d22..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class BulkEditProductModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.SKU")] - - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.OldPrice")] - public double OldPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.ManageInventoryMethod")] - public int ManageInventoryMethodId { get; set; } - - public string ManageInventoryMethod { get; set; } - - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Published")] - public bool Published { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs deleted file mode 100644 index 16ae6ab0a..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class CopyProductModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.Name")] - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.CopyImages")] - public bool CopyImages { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.Published")] - public bool Published { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs deleted file mode 100644 index 73b8c098a..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Grand.Web.Vendor.Models.Catalog; - -/// -/// Implement on any Vendor-area POST model that carries a product id. The global -/// resolves an -/// IValidator<IProductValidVendor> for every such model and rejects the request unless -/// belongs to the current vendor - see -/// Grand.Web.Vendor.Validators.Catalog.ProductValidVendor. -/// IMPORTANT: if the model carries more than one product-id-shaped field (e.g. an owning/parent id -/// plus a referenced/component id), make sure is bound to whichever id the -/// action actually mutates. A mismatch silently authorizes the wrong product - see -/// ProductModel.BundleProductModel (ProductId vs ProductBundleId) for a bug of this exact shape that -/// was found and fixed. -/// -public interface IProductValidVendor -{ - public string ProductId { get; set; } -} - -/// -/// Implement on Vendor-area POST models that relate two products (e.g. related/similar products). -/// The paired validator (ProductRelatedValidVendor) requires ownership of -/// only, because the consuming actions only ever read/mutate ProductId1's mapping list. Do not widen -/// this to accept ownership of either id ("OR") unless the action is also changed to only ever -/// mutate whichever product is actually owned - an OR check let an attacker satisfy validation via -/// ProductId2 while mutating a ProductId1 they don't own. -/// -public interface IProductRelatedValidVendor -{ - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs deleted file mode 100644 index 876b919d5..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Binders; -using Grand.Web.Common.Models; -using Microsoft.AspNetCore.Mvc; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeCombinationModel : BaseModel -{ - public string Id { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.AllowOutOfStockOrders")] - public bool AllowOutOfStockOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Text")] - public string Text { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Gtin")] - public string Gtin { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.OverriddenPrice")] - [UIHint("DoubleNullable")] - public double? OverriddenPrice { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Picture")] - public string PictureId { get; set; } - - public string PictureThumbnailUrl { get; set; } - - public IList ProductPictureModels { get; set; } = - new List(); - - public IList ProductAttributes { get; set; } = new List(); - - [ModelBinder(BinderType = typeof(CustomAttributesBinder))] - public IList SelectedAttributes { get; set; } - - public IList Warnings { get; set; } = new List(); - - public string ProductId { get; set; } - public string Attributes { get; set; } - - public bool UseMultipleWarehouses { get; set; } - - public IList WarehouseInventoryModels { get; set; } = new List(); - - #region Nested classes - - public class ProductAttributeModel : BaseEntityModel - { - public string ProductAttributeId { get; set; } - public string Name { get; set; } - public string TextPrompt { get; set; } - public bool IsRequired { get; set; } - public AttributeControlType AttributeControlType { get; set; } - public IList Values { get; set; } = new List(); - } - - public class ProductAttributeValueModel : BaseEntityModel - { - public string Name { get; set; } - - public bool IsPreSelected { get; set; } - } - - public class WarehouseInventoryModel : BaseEntityModel - { - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public string WarehouseName { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.WarehouseUsed")] - public bool WarehouseUsed { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - } - - #endregion -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs deleted file mode 100644 index 6437c3029..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Binders; -using Grand.Web.Common.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeConditionModel : BaseModel, IProductValidVendor -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.EnableCondition")] - public bool EnableCondition { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.Attributes")] - public string SelectedProductAttributeId { get; set; } - - public IList ProductAttributes { get; set; } = new List(); - - [ModelBinder(BinderType = typeof(CustomAttributesBinder))] - public IList SelectedAttributes { get; set; } - - public string ProductAttributeMappingId { get; set; } - public string ProductId { get; set; } - - #region Nested classes - - public class ProductAttributeModel : BaseEntityModel - { - public string ProductAttributeId { get; set; } - - public string Name { get; set; } - - public string TextPrompt { get; set; } - - public bool IsRequired { get; set; } - - public AttributeControlType AttributeControlType { get; set; } - - public IList Values { get; set; } = new List(); - } - - public class ProductAttributeValueModel : BaseEntityModel - { - public string Name { get; set; } - - public bool IsPreSelected { get; set; } - } - - #endregion -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs deleted file mode 100644 index 19ad1555b..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Infrastructure.Validators; -using Grand.Web.Common.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeLocalizedModel : ILocalizedModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.Fields.Description")] - [SanitizeHtml] - public string Description { get; set; } - - public string LanguageId { get; set; } -} - -public class PredefinedProductAttributeValueModel : BaseEntityModel, - ILocalizedModel -{ - public string ProductAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.PriceAdjustment")] - public double PriceAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.PriceAdjustment")] - //used only on the values list page - public string PriceAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.WeightAdjustment")] - public double WeightAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.WeightAdjustment")] - //used only on the values list page - public string WeightAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Cost")] - public double Cost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.IsPreSelected")] - public bool IsPreSelected { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public IList Locales { get; set; } = - new List(); -} - -public class PredefinedProductAttributeValueLocalizedModel : ILocalizedModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Name")] - - public string Name { get; set; } - - public string LanguageId { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs deleted file mode 100644 index 68bece177..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductListModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductName")] - public string SearchProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCategory")] - [UIHint("Category")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchIncludeSubCategories")] - public bool SearchIncludeSubCategories { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCollection")] - [UIHint("Collection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchWarehouse")] - public string SearchWarehouseId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchPublished")] - public int SearchPublishedId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.GoDirectlyToSku")] - - public string GoDirectlyToSku { get; set; } - - public IList AvailableWarehouses { get; set; } = new List(); - public IList AvailableProductTypes { get; set; } = new List(); - public IList AvailablePublishedOptions { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs deleted file mode 100644 index 4f87d65ee..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs +++ /dev/null @@ -1,992 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Models; -using Grand.Infrastructure.Validators; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductModel : BaseEntityModel, ILocalizedModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ID")] - public override string Id { get; set; } - - //picture thumbnail - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.PictureThumbnailUrl")] - public string PictureThumbnailUrl { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductType")] - public int ProductTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductType")] - public string ProductTypeName { get; set; } - - public bool AuctionEnded { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AssociatedToProductName")] - public string AssociatedToProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AssociatedToProductName")] - public string AssociatedToProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.VisibleIndividually")] - public bool VisibleIndividually { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductLayout")] - public string ProductLayoutId { get; set; } - - public IList AvailableProductLayouts { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Name")] - public string Name { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShortDescription")] - public string ShortDescription { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.FullDescription")] - public string FullDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Flag")] - public string Flag { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AdminComment")] - public string AdminComment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Brand")] - [UIHint("Brand")] - public string BrandId { get; set; } - - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaKeywords")] - public string MetaKeywords { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaDescription")] - public string MetaDescription { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaTitle")] - public string MetaTitle { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.SeName")] - public string SeName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowCustomerReviews")] - public bool AllowCustomerReviews { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.GTIN")] - public virtual string Gtin { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsGiftVoucher")] - public bool IsGiftVoucher { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.GiftVoucherType")] - public int GiftVoucherTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OverriddenGiftVoucherAmount")] - [UIHint("DoubleNullable")] - public double? OverGiftAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RequireOtherProducts")] - public bool RequireOtherProducts { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RequiredProductIds")] - public string RequiredProductIds { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AutomaticallyAddRequiredProducts")] - public bool AutoAddRequiredProducts { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsRecurring")] - public bool IsRecurring { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringCycleLength")] - public int RecurringCycleLength { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringCyclePeriod")] - public int RecurringCyclePeriodId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringTotalCycles")] - public int RecurringTotalCycles { get; set; } - - //calendar - public GenerateCalendarModel CalendarModel { get; set; } = new(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsShipEnabled")] - public bool IsShipEnabled { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsFreeShipping")] - public bool IsFreeShipping { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShipSeparately")] - public bool ShipSeparately { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AdditionalShippingCharge")] - public double AdditionalShippingCharge { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DeliveryDate")] - public string DeliveryDateId { get; set; } - - public IList AvailableDeliveryDates { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsTaxExempt")] - public bool IsTaxExempt { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.TaxCategory")] - public string TaxCategoryId { get; set; } - - public IList AvailableTaxCategories { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsTelecommunicationsOrBroadcastingOrElectronicServices")] - public bool IsTele { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ManageInventoryMethod")] - public int ManageInventoryMethodId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.UseMultipleWarehouses")] - public bool UseMultipleWarehouses { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public IList AvailableWarehouses { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StockQuantity")] - public string StockQuantityStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayStockAvailability")] - public bool StockAvailability { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayStockQuantity")] - public bool DisplayStockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MinStockQuantity")] - public int MinStockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.LowStockActivity")] - public int LowStockActivityId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BackorderMode")] - public int BackorderModeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowOutOfStockSubscriptions")] - public bool AllowOutOfStockSubscriptions { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OrderMinimumQuantity")] - public int OrderMinimumQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OrderMaximumQuantity")] - public int OrderMaximumQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowedQuantities")] - public string AllowedQuantities { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.NotReturnable")] - public bool NotReturnable { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisableBuyButton")] - public bool DisableBuyButton { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisableWishlistButton")] - public bool DisableWishlistButton { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableForPreOrder")] - public bool AvailableForPreOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.PreOrderDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? PreOrderDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CallForPrice")] - public bool CallForPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OldPrice")] - public double OldPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CatalogPrice")] - public double CatalogPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StartPrice")] - public double StartPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductCost")] - public double ProductCost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.EnteredPrice")] - public bool EnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MinEnteredPrice")] - public double MinEnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MaxEnteredPrice")] - public double MaxEnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceEnabled")] - public bool BasepriceEnabled { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceAmount")] - public double BasepriceAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceUnit")] - public string BasepriceUnitId { get; set; } - - public IList AvailableBasepriceUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceBaseAmount")] - public double BasepriceBaseAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceBaseUnit")] - public string BasepriceBaseUnitId { get; set; } - - public IList AvailableBasepriceBaseUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNew")] - public bool MarkAsNew { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNewStartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? MarkAsNewStartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNewEndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? MarkAsNewEndDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Unit")] - public string UnitId { get; set; } - - public IList AvailableUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Weight")] - public double Weight { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Length")] - public double Length { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Width")] - public double Width { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Height")] - public double Height { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableStartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? AvailableStartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableEndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? AvailableEndDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderCategory")] - public int DisplayOrderCategory { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderBrand")] - public int DisplayOrderBrand { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderCollection")] - public int DisplayOrderCollection { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderOnSale")] - public int OnSale { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Published")] - public bool Published { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CreatedOn")] - public DateTime? CreatedOn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.UpdatedOn")] - public DateTime? UpdatedOn { get; set; } - - public long Ticks { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - public string BaseDimensionIn { get; set; } - public string BaseWeightIn { get; set; } - - //product attributes - public IList AvailableProductAttributes { get; set; } = new List(); - - //pictures - public ProductPictureModel AddPictureModel { get; set; } = new(); - public IList ProductPictureModels { get; set; } = new List(); - - //multiple warehouses - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory")] - public IList ProductWarehouseInventoryModels { get; set; } = - new List(); - - //copy product - public CopyProductModel CopyProductModel { get; set; } = new(); - - public IList Locales { get; set; } = new List(); - - #region Nested classes - - public class AddProductModel : BaseModel - { - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductName")] - - public string SearchProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCategory")] - [UIHint("Category")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCollection")] - [UIHint("Collection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - public IList AvailableProductTypes { get; set; } = new List(); - } - - - public class AddRequiredProductModel : AddProductModel; - - public class AddProductSpecificationAttributeModel : BaseModel, IProductValidVendor - { - public string Id { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.SpecificationAttribute")] - public string SpecificationAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.AttributeType")] - public SpecificationAttributeType AttributeTypeId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.SpecificationAttributes.Fields.SpecificationAttributeOption")] - public string SpecificationAttributeOptionId { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.CustomName")] - public string CustomName { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.CustomValue")] - public string CustomValue { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering")] - public bool AllowFiltering { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage")] - public bool ShowOnProductPage { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public IList AvailableAttributes { get; set; } = new List(); - public IList AvailableOptions { get; set; } = new List(); - - public string ProductId { get; set; } - } - - public class ProductPictureModel : BaseEntityModel, - ILocalizedModel, IProductValidVendor - { - [UIHint("MultiPicture")] - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Picture")] - public string PictureId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Picture")] - public string PictureUrl { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Admin.Catalog.Products.Pictures.Fields.IsDefault")] - public bool IsDefault { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideAltAttribute")] - public string AltAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideTitleAttribute")] - public string TitleAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Style")] - public string Style { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.ExtraField")] - public string ExtraField { get; set; } - - public IList Locales { get; set; } = new List(); - public string ProductId { get; set; } - - public class ProductPictureLocalizedModel : ILocalizedModelLocal - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideAltAttribute")] - public string AltAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideTitleAttribute")] - public string TitleAttribute { get; set; } - - public string LanguageId { get; set; } - } - } - - public class ProductCategoryModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Categories.Fields.Category")] - public string Category { get; set; } - - public string CategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Categories.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class ProductCollectionModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Collections.Fields.Collection")] - public string Collection { get; set; } - - public string CollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Collections.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class RelatedProductModel : BaseEntityModel, IProductRelatedValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.RelatedProducts.Fields.Product")] - public string Product2Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.RelatedProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } - } - - public class AddRelatedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class SimilarProductModel : BaseEntityModel, IProductRelatedValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.SimilarProducts.Fields.Product")] - public string Product2Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SimilarProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } - } - - public class AddSimilarProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class BundleProductModel : BaseEntityModel, IProductValidVendor - { - public string ProductBundleId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.Product")] - public string ProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class AddBundleProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class AssociatedProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.AssociatedProducts.Fields.Product")] - public string ProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.AssociatedProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class AddAssociatedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class CrossSellProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.CrossSells.Fields.Product")] - public string Product2Name { get; set; } - - public string ProductId { get; set; } - } - - public class AddCrossSellProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class RecommendedProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Recommended.Fields.Product")] - public string Product2Name { get; set; } - - public string ProductId { get; set; } - } - - public class AddRecommendedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class ProductPriceModel : BaseEntityModel - { - public string CurrencyCode { get; set; } - - public double Price { get; set; } - } - - public class TierPriceModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.CurrencyCode")] - public string CurrencyCode { get; set; } - - public IList AvailableCurrencies { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.StartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? StartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.EndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? EndDateTime { get; set; } - - public string ProductId { get; set; } - } - - public class TierPriceDeleteModel : BaseEntityModel, IProductValidVendor - { - public string ProductId { get; set; } - } - - public class ProductWarehouseInventoryModel : BaseModel - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public string WarehouseCode { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse")] - public string WarehouseName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed")] - public bool WarehouseUsed { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - } - - public class ReservationModel : BaseEntityModel, IProductValidVendor - { - public string ReservationId { get; set; } - public DateTime Date { get; set; } - public string Resource { get; set; } - public string Parameter { get; set; } - public string OrderId { get; set; } - public string Duration { get; set; } - public string ProductId { get; set; } - } - - public class BidModel : BaseEntityModel, IProductValidVendor - { - public string BidId { get; set; } - public DateTime Date { get; set; } - public string CustomerId { get; set; } - public string Email { get; set; } - public string Amount { get; set; } - public string OrderId { get; set; } - public string ProductId { get; set; } - } - - public class GenerateCalendarModel : BaseModel, IProductValidVendor - { - public GenerateCalendarModel() - { - Interval = 1; - Quantity = 1; - } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.StartTime")] - [UIHint("Time")] - public DateTime StartTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [UIHint("Time")] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.EndTime")] - public DateTime EndTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Interval")] - public int Interval { get; set; } = 1; - - public int IntervalUnit { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.IncBothDate")] - public bool IncBothDate { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Resource")] - public string Resource { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Parameter")] - public string Parameter { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Monday")] - public bool Monday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Tuesday")] - public bool Tuesday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Wednesday")] - public bool Wednesday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Thursday")] - public bool Thursday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Friday")] - public bool Friday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Saturday")] - public bool Saturday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Sunday")] - public bool Sunday { get; set; } - - public string ProductId { get; set; } - } - - public class ProductAttributeMappingModel : BaseEntityModel, IProductValidVendor - { - public string ProductAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.Attribute")] - public string ProductAttribute { get; set; } - - public IList AvailableProductAttribute { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.TextPrompt")] - public string TextPrompt { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.IsRequired")] - public bool IsRequired { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.ShowOnCatalogPage")] - public bool ShowOnCatalogPage { get; set; } - - public AttributeControlType AttributeControlTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.AttributeControlType")] - public string AttributeControlType { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.Combination")] - public bool Combination { get; set; } - - public bool ShouldHaveValues { get; set; } - public int TotalValues { get; set; } - - //validation fields - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules")] - public bool ValidationRulesAllowed { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength")] - [UIHint("Int32Nullable")] - public int? ValidationMinLength { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength")] - [UIHint("Int32Nullable")] - public int? ValidationMaxLength { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions")] - - public string ValidationFileAllowedExtensions { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize")] - [UIHint("Int32Nullable")] - public int? ValidationFileMaximumSize { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue")] - - public string DefaultValue { get; set; } - - public string ValidationRulesString { get; set; } - - //condition - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition")] - public bool ConditionAllowed { get; set; } - - public string ConditionString { get; set; } - public string ProductId { get; set; } - } - - public class ProductAttributeValueListModel : BaseModel, IProductValidVendor - { - public string ProductName { get; set; } - - public string ProductAttributeMappingId { get; set; } - - public string ProductAttributeName { get; set; } - public string ProductId { get; set; } - } - - public class ProductAttributeValueModel : BaseEntityModel, ILocalizedModel, - IProductValidVendor - { - public string ProductAttributeMappingId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AttributeValueType")] - public AttributeValueType AttributeValueTypeId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AttributeValueType")] - public string AttributeValueTypeName { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct")] - public string AssociatedProductId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct")] - public string AssociatedProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.ColorSquaresRgb")] - - public string ColorSquaresRgb { get; set; } - - public bool DisplayColorSquaresRgb { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.ImageSquaresPicture")] - [UIHint("Picture")] - public string ImageSquaresPictureId { get; set; } - - public bool DisplayImageSquaresPicture { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.PriceAdjustment")] - public double PriceAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.PriceAdjustment")] - //used only on the values list page - public string PriceAdjustmentStr { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.WeightAdjustment")] - public double WeightAdjustment { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.WeightAdjustment")] - //used only on the values list page - public string WeightAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Cost")] - public double Cost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.IsPreSelected")] - public bool IsPreSelected { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Picture")] - public string PictureId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Picture")] - public string PictureThumbnailUrl { get; set; } - - public IList ProductPictureModels { get; set; } = new List(); - - public IList Locales { get; set; } = - new List(); - - public string ProductId { get; set; } - - #region Nested classes - - public class AssociateProductToAttributeValueModel : AddProductModel - { - public string AssociatedToProductId { get; set; } - } - - #endregion - } - - public class ProductAttributeValueLocalizedModel : ILocalizedModelLocal - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name")] - - public string Name { get; set; } - - public string LanguageId { get; set; } - } - - public class ProductAttributeCombinationModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Attributes")] - public string Attributes { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.AllowOutOfStockOrders")] - public bool AllowOutOfStockOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Gtin")] - public string Gtin { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.OverriddenPrice")] - [UIHint("DoubleNullable")] - public double? OverriddenPrice { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - public string ProductId { get; set; } - } - - public class ProductAttributeCombinationTierPricesModel : BaseEntityModel, IProductValidVendor - { - public string ProductAttributeCombinationId { get; set; } - - /// - /// Gets or sets the quantity - /// - public int Quantity { get; set; } - - /// - /// Gets or sets the price - /// - public double Price { get; set; } - - public string ProductId { get; set; } - } - - #endregion -} - -public class ProductLocalizedModel : ILocalizedModelLocal, ISlugModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Name")] - - public string Name { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShortDescription")] - public string ShortDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.FullDescription")] - [SanitizeHtml] - public string FullDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaKeywords")] - [NoHtml] - public string MetaKeywords { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaDescription")] - public string MetaDescription { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaTitle")] - public string MetaTitle { get; set; } - - public string LanguageId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.SeName")] - public string SeName { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs deleted file mode 100644 index 05ef05ad2..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductReviewModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Product")] - public string ProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Product")] - public string ProductName { get; set; } - - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Store")] - public string StoreName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Customer")] - public string CustomerId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Customer")] - public string CustomerInfo { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Title")] - public string Title { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.ReviewText")] - public string ReviewText { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.ReplyText")] - public string ReplyText { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Signature")] - public string Signature { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Rating")] - public int Rating { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.IsApproved")] - public bool IsApproved { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.CreatedOn")] - public DateTime CreatedOn { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs deleted file mode 100644 index ea811af69..000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductSpecificationAttributeModel : BaseEntityModel, IProductValidVendor -{ - public int AttributeTypeId { get; set; } - - public string AttributeTypeName { get; set; } - - public string AttributeName { get; set; } - - public string AttributeId { get; set; } - - public string ValueRaw { get; set; } - - public bool AllowFiltering { get; set; } - - public bool ShowOnProductPage { get; set; } - - public int DisplayOrder { get; set; } - - public string SpecificationAttributeOptionId { get; set; } - - public string ProductId { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs b/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs deleted file mode 100644 index 8b39aa20c..000000000 --- a/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs +++ /dev/null @@ -1,2326 +0,0 @@ -using Grand.Business.Core.Extensions; -using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; -using Grand.Business.Core.Interfaces.Catalog.Directory; -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.Checkout.Shipping; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Seo; -using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Business.Core.Interfaces.Customers; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Directory; -using Grand.Domain.Localization; -using Grand.Domain.Media; -using Grand.Domain.Tax; -using Grand.Infrastructure; -using Grand.SharedKernel.Extensions; -using Grand.Web.Common.Extensions; -using Grand.Web.Common.Localization; -using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.Net; -using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; - -namespace Grand.Web.Vendor.Services; - -public class ProductViewModelService( - IProductService productService, - IInventoryManageService inventoryManageService, - IPictureService pictureService, - IProductAttributeService productAttributeService, - ICurrencyService currencyService, - IMeasureService measureService, - IDateTimeService dateTimeService, - ICollectionService collectionService, - IProductCollectionService productCollectionService, - ICategoryService categoryService, - IProductCategoryService productCategoryService, - ITranslationService translationService, - IProductLayoutService productLayoutService, - ISpecificationAttributeService specificationAttributeService, - IContextAccessor contextAccessor, - IWarehouseService warehouseService, - IDeliveryDateService deliveryDateService, - ITaxCategoryService taxCategoryService, - ICustomerService customerService, - IStoreService storeService, - IOutOfStockSubscriptionService outOfStockSubscriptionService, - ILanguageService languageService, - IProductAttributeFormatter productAttributeFormatter, - IStockQuantityService stockQuantityService, - IAuctionService auctionService, - IPriceFormatter priceFormatter, - CurrencySettings currencySettings, - MeasureSettings measureSettings, - TaxSettings taxSettings, - ISeNameService seNameService, - IEnumTranslationService enumTranslationService) - : IProductViewModelService -{ - public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, - Product product) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(product); - - if (product.UseMultipleWarehouses) model.UseMultipleWarehouses = product.UseMultipleWarehouses; - - if (string.IsNullOrEmpty(model.Id)) - { - model.ProductId = product.Id; - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - foreach (var attribute in attributes) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeCombinationModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = productAttribute.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = attribute.ProductAttributeValues; - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeCombinationModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - } - - model.ProductAttributes.Add(attributeModel); - } - } - - - if (!string.IsNullOrEmpty(model.PictureId)) - { - var pictureThumbnailUrl = await pictureService.GetPictureUrl(model.PictureId, 100, false); - model.PictureThumbnailUrl = pictureThumbnailUrl; - } - - foreach (var picture in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = picture.Id, - ProductId = product.Id, - PictureId = picture.PictureId, - PictureUrl = await pictureService.GetPictureUrl(picture.PictureId), - DisplayOrder = picture.DisplayOrder, - IsDefault = picture.IsDefault - }); - - model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; - } - - public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) - { - foreach (var currency in await currencyService.GetAllCurrencies()) - model.AvailableCurrencies.Add( - new SelectListItem { Text = currency.Name, Value = currency.CurrencyCode }); - } - - public virtual async Task PrepareProductAttributeValueModel(Product product, - ProductModel.ProductAttributeValueModel model) - { - //pictures - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - - var associatedProduct = await productService.GetProductById(model.AssociatedProductId); - model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; - } - - public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, - List prevMultiWarehouseStock - ) - { - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - stockQuantityService.GetTotalStockQuantity(product, total: true) > 0 && - prevStockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - product.UseMultipleWarehouses && - product.Published) - { - foreach (var prevstock in prevMultiWarehouseStock) - if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) - { - var actualStock = - product.ProductWarehouseInventory.FirstOrDefault( - x => x.WarehouseId == prevstock.WarehouseId); - if (actualStock != null) - if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - prevstock.WarehouseId); - } - - if (product.ProductWarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) - if (prevMultiWarehouseStock.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - } - } - - public virtual async Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination) - { - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - combination.StockQuantity > 0 && - prevcombination.StockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, - ""); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - product.UseMultipleWarehouses && - product.Published) - { - foreach (var prevstock in prevcombination.WarehouseInventory) - if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) - { - var actualStock = - combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == prevstock.WarehouseId); - if (actualStock != null) - if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - combination.Attributes, prevstock.WarehouseId); - } - - if (combination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) - if (prevcombination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - combination.Attributes, ""); - } - } - - public virtual async Task PrepareProductModel(ProductModel model, Product product, - bool setPredefinedValues) - { - ArgumentNullException.ThrowIfNull(model); - - model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; - model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId))?.Name; - model.BaseDimensionIn = - (await measureService.GetMeasureDimensionById(measureSettings.BaseDimensionId))?.Name; - - if (product != null) - { - //date - model.CreatedOn = dateTimeService.ConvertToUserTime(product.CreatedOnUtc, DateTimeKind.Utc); - model.UpdatedOn = product.UpdatedOnUtc.HasValue - ? dateTimeService.ConvertToUserTime(product.UpdatedOnUtc.Value, DateTimeKind.Utc) - : null; - - //parent grouped product - var parentGroupedProduct = await productService.GetProductById(product.ParentGroupedProductId); - if (parentGroupedProduct != null) - { - model.AssociatedToProductId = product.ParentGroupedProductId; - model.AssociatedToProductName = parentGroupedProduct.Name; - } - - //reservation - model.CalendarModel.ProductId = product.Id; - model.CalendarModel.Interval = product.Interval; - model.CalendarModel.IntervalUnit = (int)product.IntervalUnitId; - model.CalendarModel.IncBothDate = product.IncBothDate; - - model.AutoAddRequiredProducts = product.AutoAddRequiredProducts; - //product attributes - foreach (var productAttribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttributes.Add(new SelectListItem { - Text = productAttribute.Name, - Value = productAttribute.Id - }); - } - - //copy product - if (product != null) - { - model.CopyProductModel.Id = product.Id; - model.CopyProductModel.Name = "Copy of " + product.Name; - model.CopyProductModel.Published = true; - model.CopyProductModel.CopyImages = true; - } - - //layouts - var layouts = await productLayoutService.GetAllProductLayouts(); - foreach (var layout in layouts) - model.AvailableProductLayouts.Add(new SelectListItem { - Text = layout.Name, - Value = layout.Id - }); - - //delivery dates - model.AvailableDeliveryDates.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.Fields.DeliveryDate.None"), - Value = "" - }); - var deliveryDates = await deliveryDateService.GetAllDeliveryDates(); - foreach (var deliveryDate in deliveryDates) - model.AvailableDeliveryDates.Add(new SelectListItem { - Text = deliveryDate.Name, - Value = deliveryDate.Id - }); - - //warehouses - var warehouses = await warehouseService.GetAllWarehouses(); - model.AvailableWarehouses.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.Fields.Warehouse.None"), - Value = "" - }); - foreach (var warehouse in warehouses) - model.AvailableWarehouses.Add(new SelectListItem { - Text = warehouse.Name, - Value = warehouse.Id - }); - - //multiple warehouses - foreach (var warehouse in warehouses) - { - var pwiModel = new ProductModel.ProductWarehouseInventoryModel { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code - }; - if (product != null) - { - var pwi = product.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (pwi != null) - { - pwiModel.WarehouseUsed = true; - pwiModel.StockQuantity = pwi.StockQuantity; - pwiModel.ReservedQuantity = pwi.ReservedQuantity; - } - } - - model.ProductWarehouseInventoryModels.Add(pwiModel); - } - - //tax categories - var taxCategories = await taxCategoryService.GetAllTaxCategories(); - model.AvailableTaxCategories.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Configuration.Tax.Settings.TaxCategories.None"), - Value = "" - }); - foreach (var tc in taxCategories) - model.AvailableTaxCategories.Add(new SelectListItem { - Text = tc.Name, Value = tc.Id, - Selected = product != null && !setPredefinedValues && tc.Id == product.TaxCategoryId - }); - - //base-price units - var measureWeights = await measureService.GetAllMeasureWeights(); - foreach (var mw in measureWeights) - model.AvailableBasepriceUnits.Add(new SelectListItem { - Text = mw.Name, Value = mw.Id, - Selected = product != null && !setPredefinedValues && mw.Id == product.BasepriceUnitId - }); - foreach (var mw in measureWeights) - model.AvailableBasepriceBaseUnits.Add(new SelectListItem { - Text = mw.Name, Value = mw.Id, - Selected = product != null && !setPredefinedValues && mw.Id == product.BasepriceBaseUnitId - }); - - //units - var units = await measureService.GetAllMeasureUnits(); - model.AvailableUnits.Add(new SelectListItem { Text = "---", Value = "" }); - foreach (var un in units) - model.AvailableUnits.Add(new SelectListItem - { Text = un.Name, Value = un.Id, Selected = product != null && un.Id == product.UnitId }); - - //default values - if (setPredefinedValues) - { - model.MaxEnteredPrice = 1000; - model.RecurringCycleLength = 100; - model.RecurringTotalCycles = 10; - model.StockQuantity = 0; - model.NotifyAdminForQuantityBelow = 1; - model.OrderMinimumQuantity = 1; - model.OrderMaximumQuantity = 10000; - model.TaxCategoryId = taxSettings.DefaultTaxCategoryId; - model.IsShipEnabled = true; - model.AllowCustomerReviews = true; - model.Published = true; - model.VisibleIndividually = true; - } - } - - public virtual async Task SaveProductWarehouseInventory(Product product, - IList model) - { - ArgumentNullException.ThrowIfNull(product); - - if (product.ManageInventoryMethodId != ManageInventoryMethod.ManageStock) - return; - - if (!product.UseMultipleWarehouses) - return; - - var warehouses = await warehouseService.GetAllWarehouses(); - - foreach (var warehouse in warehouses) - { - var whim = model.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - var existingPwI = product.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (existingPwI != null) - { - if (whim is { WarehouseUsed: true }) - { - //update existing record - existingPwI.StockQuantity = whim.StockQuantity; - existingPwI.ReservedQuantity = whim.ReservedQuantity; - await productService.UpdateProductWarehouseInventory(existingPwI, product.Id); - } - else - { - //delete. no need to store record for qty 0 - await productService.DeleteProductWarehouseInventory(existingPwI, product.Id); - } - } - else - { - if (whim is { WarehouseUsed: true }) - { - //no need to insert a record for qty 0 - existingPwI = new ProductWarehouseInventory { - WarehouseId = warehouse.Id, - StockQuantity = whim.StockQuantity, - ReservedQuantity = whim.ReservedQuantity - }; - product.ProductWarehouseInventory.Add(existingPwI); - await productService.InsertProductWarehouseInventory(existingPwI, product.Id); - } - } - } - - product.StockQuantity = product.ProductWarehouseInventory.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductWarehouseInventory.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - - public virtual async Task PrepareProductReviewModel(ProductReviewModel model, - ProductReview productReview, bool excludeProperties, bool formatReviewText) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(productReview); - - var product = await productService.GetProductById(productReview.ProductId); - var customer = await customerService.GetCustomerById(productReview.CustomerId); - var store = await storeService.GetStoreById(productReview.StoreId); - model.Id = productReview.Id; - model.StoreName = store != null ? store.Shortcut : ""; - model.ProductId = productReview.ProductId; - model.ProductName = product.Name; - model.CustomerId = productReview.CustomerId; - model.CustomerInfo = customer != null - ? !string.IsNullOrEmpty(customer.Email) - ? customer.Email - : translationService.GetResource("Vendor.Customers.Guest") - : ""; - model.Rating = productReview.Rating; - model.CreatedOn = dateTimeService.ConvertToUserTime(productReview.CreatedOnUtc, DateTimeKind.Utc); - model.Signature = productReview.Signature; - if (!excludeProperties) - { - model.Title = productReview.Title; - if (formatReviewText) - { - model.ReviewText = FormatText.ConvertText(productReview.ReviewText); - model.ReplyText = FormatText.ConvertText(productReview.ReplyText); - } - else - { - model.ReviewText = productReview.ReviewText; - model.ReplyText = productReview.ReplyText; - } - - model.IsApproved = productReview.IsApproved; - } - } - - public virtual async Task PrepareProductListModel() - { - var model = new ProductListModel(); - - //warehouses - model.AvailableWarehouses.Add(new SelectListItem - { Text = translationService.GetResource("Vendor.Common.All"), Value = " " }); - foreach (var wh in await warehouseService.GetAllWarehouses()) - model.AvailableWarehouses.Add(new SelectListItem { Text = wh.Name, Value = wh.Id }); - - //product types - model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - //"published" property - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - //4 - mark as new - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.All"), Value = " " - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.PublishedOnly"), - Value = "1" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.UnpublishedOnly"), - Value = "2" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.MarkAsNew"), - Value = "4" - }); - - return model; - } - - public virtual async Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel( - ProductListModel model, int pageIndex, int pageSize) - { - var categoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.Add(model.SearchCategoryId); - - //include subcategories - if (model.SearchIncludeSubCategories && !string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.AddRange(await GetChildCategoryIds(model.SearchCategoryId)); - - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - bool? overridePublished = model.SearchPublishedId switch { - 1 => true, - 2 => false, - _ => null - }; - - bool? showOnHomePage = null; - if (model.SearchPublishedId == 3) - showOnHomePage = true; - - var markedAsNewOnly = model.SearchPublishedId == 4; - - var products = (await productService.SearchProducts( - categoryIds: categoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - warehouseId: model.SearchWarehouseId, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - pageIndex: pageIndex - 1, - pageSize: pageSize, - showHidden: true, - showOnHomePage: showOnHomePage, - overridePublished: overridePublished, - markedAsNewOnly: markedAsNewOnly - )).products; - - var items = new List(); - foreach (var x in products) - { - var productModel = x.ToModel(dateTimeService); - //"Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property. " - //also it improves performance - productModel.FullDescription = ""; - - //picture - var defaultProductPicture = x.ProductPictures.FirstOrDefault() ?? new ProductPicture(); - productModel.PictureThumbnailUrl = - await pictureService.GetPictureUrl(defaultProductPicture.PictureId, 100); - //product type - productModel.ProductTypeName = enumTranslationService.GetTranslationEnum(x.ProductTypeId); - //friendly stock quantity - //if a simple product AND "manage inventory" is "Track inventory", then display - if (x.ProductTypeId == ProductType.SimpleProduct && - x.ManageInventoryMethodId == ManageInventoryMethod.ManageStock) - productModel.StockQuantityStr = - stockQuantityService.GetTotalStockQuantity(x, total: true).ToString(); - items.Add(productModel); - } - - return (items, products.TotalCount); - } - - public virtual async Task> PrepareProducts(ProductListModel model) - { - var categoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.Add(model.SearchCategoryId); - - //include subcategories - if (model.SearchIncludeSubCategories && !string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.AddRange(await GetChildCategoryIds(model.SearchCategoryId)); - - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - bool? overridePublished = model.SearchPublishedId switch { - 1 => true, - 2 => false, - _ => null - }; - - var products = (await productService.SearchProducts( - categoryIds: categoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - warehouseId: model.SearchWarehouseId, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - showHidden: true, - overridePublished: overridePublished - )).products; - - return products; - } - - public virtual async Task InsertProductModel(ProductModel model) - { - //product - var product = model.ToEntity(dateTimeService); - product.VendorId = contextAccessor.WorkContext.CurrentVendor?.Id; - - product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); - product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); - - await productService.InsertProduct(product); - - //search engine name - await seNameService.SaveSeName(product); - - //warehouses - await SaveProductWarehouseInventory(product, model.ProductWarehouseInventoryModels); - - return product; - } - - public virtual async Task UpdateProductModel(Product product, ProductModel model) - { - var prevStockQuantity = stockQuantityService.GetTotalStockQuantity(product, total: true); - var prevMultiWarehouseStock = product.ProductWarehouseInventory.Select(i => new ProductWarehouseInventory { - WarehouseId = i.WarehouseId, StockQuantity = i.StockQuantity, ReservedQuantity = i.ReservedQuantity - }) - .ToList(); - - //product - product = model.ToEntity(product, dateTimeService); - product.AutoAddRequiredProducts = model.AutoAddRequiredProducts; - - product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); - product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); - - await productService.UpdateProduct(product); - - //search engine name - await seNameService.SaveSeName(product); - - //warehouses - await SaveProductWarehouseInventory(product, model.ProductWarehouseInventoryModels); - - //picture seo names - await UpdatePictureSeoNames(product); - - //out of stock notifications - await OutOfStockNotifications(product, prevStockQuantity, prevMultiWarehouseStock); - - return product; - } - - public virtual async Task DeleteProduct(Product product) - { - await productService.DeleteProduct(product); - } - - public virtual async Task DeleteSelected(IEnumerable selectedIds) - { - var products = new List(); - products.AddRange(await productService.GetProductsByIds(selectedIds.ToArray(), true)); - for (var i = 0; i < products.Count; i++) - { - var product = products[i]; - //a vendor should have access only to his products - if (!contextAccessor.WorkContext.HasAccessToProduct(product)) - continue; - - await DeleteProduct(product); - } - } - - public virtual Task PrepareAddRequiredProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual async Task<(IList products, int totalCount)> PrepareProductModel( - ProductModel.AddProductModel model, int pageIndex, int pageSize) - { - var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, - model.SearchCollectionId, string.Empty, contextAccessor.WorkContext.CurrentVendor.Id, model.SearchProductTypeId, - model.SearchProductName, pageIndex, pageSize); - return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); - } - - public virtual async Task> PrepareProductCategoryModel(Product product) - { - var productCategories = product.ProductCategories.OrderBy(x => x.DisplayOrder); - var items = new List(); - foreach (var x in productCategories) - { - var category = await categoryService.GetCategoryById(x.CategoryId); - items.Add(new ProductModel.ProductCategoryModel { - Id = x.Id, - Category = await categoryService.GetFormattedBreadCrumb(category), - ProductId = product.Id, - CategoryId = x.CategoryId, - DisplayOrder = x.DisplayOrder - }); - } - - return items; - } - - public virtual async Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - if (product.ProductCategories.All(x => x.CategoryId != model.CategoryId)) - { - var productCategory = new ProductCategory { - CategoryId = model.CategoryId, - DisplayOrder = model.DisplayOrder - }; - await productCategoryService.InsertProductCategory(productCategory, product.Id); - } - } - - public virtual async Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == model.Id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - if (product.ProductCategories.Any(x => x.Id != model.Id && x.CategoryId == model.CategoryId)) - throw new ArgumentException("This category is already mapped with this product"); - productCategory.CategoryId = model.CategoryId; - productCategory.DisplayOrder = model.DisplayOrder; - - await productCategoryService.UpdateProductCategory(productCategory, product.Id); - } - - public virtual async Task DeleteProductCategory(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - await productCategoryService.DeleteProductCategory(productCategory, product.Id); - } - - public virtual async Task> PrepareProductCollectionModel( - Product product) - { - var items = new List(); - foreach (var x in product.ProductCollections.OrderBy(x => x.DisplayOrder)) - items.Add(new ProductModel.ProductCollectionModel { - Id = x.Id, - Collection = (await collectionService.GetCollectionById(x.CollectionId)).Name, - ProductId = product.Id, - CollectionId = x.CollectionId, - DisplayOrder = x.DisplayOrder - }); - - return items; - } - - public virtual async Task InsertProductCollection(ProductModel.ProductCollectionModel model) - { - var collectionId = model.CollectionId; - var product = await productService.GetProductById(model.ProductId, true); - - if (product.ProductCollections.All(x => x.CollectionId != collectionId)) - { - var productCollection = new ProductCollection { - CollectionId = collectionId, - DisplayOrder = model.DisplayOrder - }; - await productCollectionService.InsertProductCollection(productCollection, model.ProductId); - } - } - - public virtual async Task UpdateProductCollection(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == model.Id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - if (product.ProductCollections.Any(x => x.Id != model.Id && x.CollectionId == model.CollectionId)) - throw new ArgumentException("This collection is already mapped with this product"); - - productCollection.CollectionId = model.CollectionId; - productCollection.DisplayOrder = model.DisplayOrder; - - await productCollectionService.UpdateProductCollection(productCollection, product.Id); - } - - public virtual async Task DeleteProductCollection(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - await productCollectionService.DeleteProductCollection(productCollection, product.Id); - } - - public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - var existingRelatedProducts = productId1.RelatedProducts; - if (model.ProductId == id) continue; - if (existingRelatedProducts.All(x => x.ProductId2 != id)) - { - var related = new RelatedProduct { - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.RelatedProducts.Add(related); - await productService.InsertRelatedProduct(related, model.ProductId); - } - } - } - - public virtual async Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - - var relatedProduct = product1.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - relatedProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - - var relatedProduct = product.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - await productService.DeleteRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - { - var existingSimilarProducts = productId1.SimilarProducts; - if (model.ProductId != id) - if (existingSimilarProducts.All(x => x.ProductId2 != id)) - { - var similar = new SimilarProduct { - ProductId1 = model.ProductId, - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.SimilarProducts.Add(similar); - await productService.InsertSimilarProduct(similar); - } - } - } - } - - public virtual async Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product1.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - similarProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateSimilarProduct(similarProduct); - } - - public virtual async Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - await productService.DeleteSimilarProduct(similarProduct); - } - - public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - { - var existingBundleProducts = productId1.BundleProducts; - if (model.ProductId != id) - if (existingBundleProducts.All(x => x.ProductId != id)) - { - var bundle = new BundleProduct { - ProductId = id, - DisplayOrder = 1, - Quantity = 1 - }; - productId1.BundleProducts.Add(bundle); - await productService.InsertBundleProduct(bundle, model.ProductId); - } - } - } - } - - public virtual async Task UpdateBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - //the IProductValidVendor filter validates model.ProductId (the bundled item), not - //model.ProductBundleId (the product actually mutated below) - check it explicitly - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - bundleProduct.ProductId = model.ProductId; - bundleProduct.Quantity = model.Quantity > 0 ? model.Quantity : 1; - bundleProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task DeleteBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - //the IProductValidVendor filter validates model.ProductId (the bundled item), not - //model.ProductBundleId (the product actually mutated below) - check it explicitly - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - await productService.DeleteBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model) - { - var crossSellProduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product) && - crossSellProduct.CrossSellProduct.All(x => x != id)) - if (model.ProductId != id) - await productService.InsertCrossSellProduct( - new CrossSellProduct { - ProductId1 = model.ProductId, - ProductId2 = id - }); - } - } - - public virtual async Task DeleteCrossSellProduct(string productId, string crossSellProductId) - { - var crosssell = new CrossSellProduct { - ProductId1 = productId, - ProductId2 = crossSellProductId - }; - await productService.DeleteCrossSellProduct(crosssell); - } - - public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model) - { - var mainproduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - if (mainproduct.RecommendedProduct.All(x => x != id)) - if (model.ProductId != id) - await productService.InsertRecommendedProduct(model.ProductId, id); - } - } - - public virtual async Task DeleteRecommendedProduct(string productId, string recommendedProductId) - { - await productService.DeleteRecommendedProduct(productId, recommendedProductId); - } - - public virtual async Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model) - { - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - product.ParentGroupedProductId = model.ProductId; - await productService.UpdateAssociatedProduct(product); - } - } - - public virtual async Task DeleteAssociatedProduct(Product product) - { - product.ParentGroupedProductId = ""; - await productService.UpdateAssociatedProduct(product); - } - - public virtual Task PrepareRelatedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareSimilarProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareBundleProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareCrossSellProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareRecommendedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareAssociatedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareBulkEditListModel() - { - var model = new BulkEditListModel { - //product types - AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList() - }; - - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - return Task.FromResult(model); - } - - public virtual async Task<(IEnumerable bulkEditProductModels, int totalCount)> - PrepareBulkEditProductModel(BulkEditListModel model, int pageIndex, int pageSize) - { - var searchCategoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - searchCategoryIds.Add(model.SearchCategoryId); - - var products = (await productService.SearchProducts(categoryIds: searchCategoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - pageIndex: pageIndex - 1, - pageSize: pageSize, - showHidden: true)).products; - - return (products.Select((Func)(x => - { - var productModel = new BulkEditProductModel { - Id = x.Id, - Name = x.Name, - Sku = x.Sku, - OldPrice = x.OldPrice, - Price = x.Price, - ManageInventoryMethodId = (int)x.ManageInventoryMethodId, - ManageInventoryMethod = enumTranslationService.GetTranslationEnum(x.ManageInventoryMethodId), - StockQuantity = x.StockQuantity, - Published = x.Published - }; - return productModel; - })), products.TotalCount); - } - - public virtual async Task UpdateBulkEdit(IEnumerable products) - { - foreach (var pModel in products) - { - //update - var product = await productService.GetProductById(pModel.Id, true); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - var prevStockQuantity = stockQuantityService.GetTotalStockQuantity(product, total: true); - - product.Sku = pModel.Sku; - product.Price = pModel.Price; - product.OldPrice = pModel.OldPrice; - product.StockQuantity = pModel.StockQuantity; - product.Published = pModel.Published; - product.Name = pModel.Name; - product.ManageInventoryMethodId = (ManageInventoryMethod)pModel.ManageInventoryMethodId; - await productService.UpdateProduct(product); - - //out of stock notifications - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - stockQuantityService.GetTotalStockQuantity(product, total: true) > 0 && - prevStockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - } - } - - public virtual async Task DeleteBulkEdit(IEnumerable products) - { - foreach (var pModel in products) - { - //delete - var product = await productService.GetProductById(pModel.Id, true); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - await productService.DeleteProduct(product); - } - } - - public virtual Task> PrepareTierPriceModel(Product product) - { - var items = new List(); - foreach (var x in product.TierPrices - .OrderBy(x => x.StoreId) - .ThenBy(x => x.Quantity)) - items.Add(new ProductModel.TierPriceModel { - Id = x.Id, - CurrencyCode = x.CurrencyCode, - ProductId = product.Id, - Quantity = x.Quantity, - Price = x.Price, - StartDateTime = x.StartDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.StartDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?(), - EndDateTime = x.EndDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.EndDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?() - }); - - return Task.FromResult>(items); - } - - public virtual async Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode( - string productId, int pageIndex, int pageSize) - { - var bids = await auctionService.GetBidsByProductId(productId, pageIndex - 1, pageSize); - var bidsModel = new List(); - foreach (var x in bids) - bidsModel.Add(new ProductModel.BidModel { - BidId = x.Id, - ProductId = x.ProductId, - Amount = priceFormatter.FormatPrice(x.Amount), - Date = dateTimeService.ConvertToUserTime(x.Date, DateTimeKind.Utc), - CustomerId = x.CustomerId, - Email = (await customerService.GetCustomerById(x.CustomerId))?.Email, - OrderId = x.OrderId - }); - - return (bidsModel, bids.TotalCount); - } - - public virtual async Task PrepareProductAttributeMappingModel( - Product product) - { - var model = new ProductModel.ProductAttributeMappingModel { - ProductId = product.Id - }; - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping) - { - var model = productAttributeMapping.ToModel(); - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name, - Selected = attribute.Id == model.ProductAttributeId - }); - - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model) - { - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - - return model; - } - - public virtual async Task> - PrepareProductAttributeMappingModels(Product product) - { - var items = new List(); - foreach (var x in product.ProductAttributeMappings.OrderBy(x => x.DisplayOrder)) - { - var attributeModel = new ProductModel.ProductAttributeMappingModel { - Id = x.Id, - ProductId = product.Id, - ProductAttribute = (await productAttributeService.GetProductAttributeById(x.ProductAttributeId)) - ?.Name, - ProductAttributeId = x.ProductAttributeId, - TextPrompt = x.TextPrompt, - IsRequired = x.IsRequired, - ShowOnCatalogPage = x.ShowOnCatalogPage, - AttributeControlType = enumTranslationService.GetTranslationEnum(x.AttributeControlTypeId), - AttributeControlTypeId = x.AttributeControlTypeId, - DisplayOrder = x.DisplayOrder, - Combination = x.Combination - }; - - - if (x.ShouldHaveValues()) - { - attributeModel.ShouldHaveValues = true; - attributeModel.TotalValues = x.ProductAttributeValues.Count; - } - - if (x.ValidationRulesAllowed()) - { - var validationRules = new StringBuilder(string.Empty); - attributeModel.ValidationRulesAllowed = true; - if (x.ValidationMinLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength"), - x.ValidationMinLength); - if (x.ValidationMaxLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength"), - x.ValidationMaxLength); - if (!string.IsNullOrEmpty(x.ValidationFileAllowedExtensions)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions"), - WebUtility.HtmlEncode(x.ValidationFileAllowedExtensions)); - if (x.ValidationFileMaximumSize != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize"), - x.ValidationFileMaximumSize); - if (!string.IsNullOrEmpty(x.DefaultValue)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue"), - WebUtility.HtmlEncode(x.DefaultValue)); - attributeModel.ValidationRulesString = validationRules.ToString(); - } - - //currency any attribute can have condition. why not? - attributeModel.ConditionAllowed = true; - var conditionAttribute = product.ParseProductAttributeMappings(x.ConditionAttribute).FirstOrDefault(); - var conditionValue = product.ParseProductAttributeValues(x.ConditionAttribute).FirstOrDefault(); - if (conditionAttribute != null && conditionValue != null) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(conditionAttribute.ProductAttributeId); - var paname = productAttribute != null ? productAttribute.Name : ""; - attributeModel.ConditionString = - $"{WebUtility.HtmlEncode(paname)}: {WebUtility.HtmlEncode(conditionValue.Name)}"; - } - else - { - attributeModel.ConditionString = string.Empty; - } - - items.Add(attributeModel); - } - - return items; - } - - public virtual async Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - //insert mapping - var productAttributeMapping = model.ToEntity(); - //predefined values - var predefinedValues = (await productAttributeService.GetProductAttributeById(model.ProductAttributeId)) - .PredefinedProductAttributeValues; - foreach (var predefinedValue in predefinedValues) - { - var pav = predefinedValue.ToEntity(); - //locales - pav.Locales.Clear(); - var languages = await languageService.GetAllLanguages(true); - //localization - foreach (var lang in languages) - { - var name = predefinedValue.GetTranslation(x => x.Name, lang.Id, false); - if (!string.IsNullOrEmpty(name)) - pav.Locales.Add(new TranslationEntity - { LanguageId = lang.Id, LocaleKey = "Name", LocaleValue = name }); - } - - productAttributeMapping.ProductAttributeValues.Add(pav); - } - - await productAttributeService.InsertProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product != null) - { - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping != null) - { - productAttributeMapping = model.ToEntity(productAttributeMapping); - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, - model.ProductId); - } - } - } - - public virtual async Task UpdateProductAttributeValidationRulesModel( - ProductAttributeMapping productAttributeMapping, ProductModel.ProductAttributeMappingModel model) - { - productAttributeMapping.ValidationMinLength = model.ValidationMinLength; - productAttributeMapping.ValidationMaxLength = model.ValidationMaxLength; - productAttributeMapping.ValidationFileAllowedExtensions = model.ValidationFileAllowedExtensions; - productAttributeMapping.ValidationFileMaximumSize = model.ValidationFileMaximumSize; - productAttributeMapping.DefaultValue = model.DefaultValue; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping) - { - var model = new ProductAttributeConditionModel { - ProductAttributeMappingId = productAttributeMapping.Id, - EnableCondition = productAttributeMapping.ConditionAttribute.Any(), - ProductId = product.Id - }; - //pre-select attribute and values - var selectedPva = product.ParseProductAttributeMappings(productAttributeMapping.ConditionAttribute) - .FirstOrDefault(); - - var attributes = product.ProductAttributeMappings - //ignore non-combinable attributes (should have selectable values) - .Where(x => x.CanBeUsedAsCondition()) - //ignore this attribute (it cannot depend on itself) - .Where(x => x.Id != productAttributeMapping.Id) - .ToList(); - foreach (var attribute in attributes) - { - var pam = await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeConditionModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = pam.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == attribute.Id) - ?.ProductAttributeValues; - if (attributeValues != null) - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeConditionModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - - //pre-select attribute and value - if (selectedPva != null && attribute.Id == selectedPva.Id) - { - //attribute - model.SelectedProductAttributeId = selectedPva.Id; - - //values - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.Checkboxes: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - if (productAttributeMapping.ConditionAttribute.Any()) - { - //clear default selection - foreach (var item in attributeModel.Values) - item.IsPreSelected = false; - - //select new values - var selectedValues = - product.ParseProductAttributeValues(productAttributeMapping.ConditionAttribute); - foreach (var attributeValue in selectedValues) - foreach (var item in attributeModel.Values) - if (attributeValue.Id == item.Id) - item.IsPreSelected = true; - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - } - - model.ProductAttributes.Add(attributeModel); - } - - return model; - } - - public virtual async Task UpdateProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping, ProductAttributeConditionModel model) - { - var customAttributes = new List(); - if (model.EnableCondition) - { - var attribute = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.SelectedProductAttributeId); - if (attribute != null) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, ctrlAttributes).ToList(); - else - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - { - var anyValueSelected = false; - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - { - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, item).ToList(); - anyValueSelected = true; - } - - if (!anyValueSelected) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - else - { - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - - productAttributeMapping.ConditionAttribute = customAttributes; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeValueModel( - Product product, ProductAttributeMapping productAttributeMapping) - { - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = productAttributeMapping.Id, - ProductId = product.Id, - - //color squares - DisplayColorSquaresRgb = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ColorSquares, - ColorSquaresRgb = "#000000", - //image squares - DisplayImageSquaresPicture = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ImageSquares, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, - //default quantity for associated product - Quantity = 1 - }; - - //pictures - foreach (var x in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - - return model; - } - - public virtual async Task> PrepareProductAttributeValueModels( - Product product, ProductAttributeMapping productAttributeMapping) - { - var items = new List(); - foreach (var x in productAttributeMapping.ProductAttributeValues.OrderBy(x => x.DisplayOrder)) - { - Product associatedProduct = null; - if (x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct) - associatedProduct = await productService.GetProductById(x.AssociatedProductId); - - var pictureThumbnailUrl = await pictureService.GetPictureUrl( - string.IsNullOrEmpty(x.PictureId) ? x.ImageSquaresPictureId : x.PictureId, 100, false); - - if (string.IsNullOrEmpty(pictureThumbnailUrl)) - pictureThumbnailUrl = await pictureService.GetPictureUrl("", 1); - - items.Add(new ProductModel.ProductAttributeValueModel { - Id = x.Id, - ProductAttributeMappingId = productAttributeMapping.Id, //TODO - check x.ProductAttributeMappingId, - AttributeValueTypeId = x.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(x.AttributeValueTypeId), - AssociatedProductId = x.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = productAttributeMapping.AttributeControlTypeId != AttributeControlType.ColorSquares - ? x.Name - : $"{x.Name} - {x.ColorSquaresRgb}", - ColorSquaresRgb = x.ColorSquaresRgb, - ImageSquaresPictureId = x.ImageSquaresPictureId, - PriceAdjustment = x.PriceAdjustment, - PriceAdjustmentStr = x.PriceAdjustment.ToString("G29"), - WeightAdjustment = x.WeightAdjustment, - WeightAdjustmentStr = x.AttributeValueTypeId == AttributeValueType.Simple - ? x.WeightAdjustment.ToString("G29") - : "", - Cost = x.Cost, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) - ?.CurrencyCode, - Quantity = x.Quantity, - IsPreSelected = x.IsPreSelected, - DisplayOrder = x.DisplayOrder, - PictureId = x.PictureId, - PictureThumbnailUrl = pictureThumbnailUrl, - ProductId = product.Id - }); - } - - return items; - } - - public virtual async Task PrepareProductAttributeValueModel( - ProductAttributeMapping pa, ProductAttributeValue pav) - { - var associatedProduct = await productService.GetProductById(pav.AssociatedProductId); - - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = pa.Id, //TODO - check pav.ProductAttributeMappingId, - AttributeValueTypeId = pav.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(pav.AttributeValueTypeId), - AssociatedProductId = pav.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = pav.Name, - ColorSquaresRgb = pav.ColorSquaresRgb, - DisplayColorSquaresRgb = pa.AttributeControlTypeId == AttributeControlType.ColorSquares, - ImageSquaresPictureId = pav.ImageSquaresPictureId, - DisplayImageSquaresPicture = pa.AttributeControlTypeId == AttributeControlType.ImageSquares, - PriceAdjustment = pav.PriceAdjustment, - WeightAdjustment = pav.WeightAdjustment, - Cost = pav.Cost, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, - Quantity = pav.Quantity, - IsPreSelected = pav.IsPreSelected, - DisplayOrder = pav.DisplayOrder, - PictureId = pav.PictureId - }; - if (model.DisplayColorSquaresRgb && string.IsNullOrEmpty(model.ColorSquaresRgb)) - model.ColorSquaresRgb = "#000000"; - - return model; - } - - public virtual async Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model) - { - var pav = new ProductAttributeValue { - AttributeValueTypeId = model.AttributeValueTypeId, - AssociatedProductId = model.AssociatedProductId, - Name = model.Name, - ColorSquaresRgb = model.ColorSquaresRgb, - ImageSquaresPictureId = model.ImageSquaresPictureId, - PriceAdjustment = model.PriceAdjustment, - WeightAdjustment = model.WeightAdjustment, - Cost = model.Cost, - Quantity = model.Quantity, - IsPreSelected = model.IsPreSelected, - DisplayOrder = model.DisplayOrder, - PictureId = model.PictureId, - Locales = model.Locales.ToTranslationProperty() - }; - await productAttributeService.InsertProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - - public virtual async Task UpdateProductAttributeValueModel(ProductAttributeValue pav, - ProductModel.ProductAttributeValueModel model) - { - pav.AttributeValueTypeId = model.AttributeValueTypeId; - pav.AssociatedProductId = model.AssociatedProductId; - pav.Name = model.Name; - pav.ColorSquaresRgb = model.ColorSquaresRgb; - pav.ImageSquaresPictureId = model.ImageSquaresPictureId; - pav.PriceAdjustment = model.PriceAdjustment; - pav.WeightAdjustment = model.WeightAdjustment; - pav.Cost = model.Cost; - pav.Quantity = model.Quantity; - pav.IsPreSelected = model.IsPreSelected; - pav.DisplayOrder = model.DisplayOrder; - pav.PictureId = model.PictureId; - pav.Locales = model.Locales.ToTranslationProperty(); - - await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - - public virtual Task - PrepareAssociateProductToAttributeValueModel() - { - var model = PrepareAddProductModel< - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel>(); - return Task.FromResult(model); - } - - public virtual async Task> - PrepareProductAttributeCombinationModel(Product product) - { - var items = new List(); - - foreach (var x in product.ProductAttributeCombinations) - { - var attributes = await productAttributeFormatter.FormatAttributes(product, x.Attributes, - contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false, true, true); - var pacModel = new ProductModel.ProductAttributeCombinationModel { - Id = x.Id, - ProductId = product.Id, - Attributes = string.IsNullOrEmpty(attributes) ? "(null)" : attributes, - StockQuantity = product.UseMultipleWarehouses - ? x.WarehouseInventory.Sum(y => y.StockQuantity - y.ReservedQuantity) - : x.StockQuantity, - AllowOutOfStockOrders = x.AllowOutOfStockOrders, - Sku = x.Sku, - Mpn = x.Mpn, - Gtin = x.Gtin, - OverriddenPrice = x.OverriddenPrice, - NotifyAdminForQuantityBelow = x.NotifyAdminForQuantityBelow - }; - items.Add(pacModel); - } - - return items; - } - - public virtual async Task PrepareProductAttributeCombinationModel( - Product product, string combinationId) - { - var model = new ProductAttributeCombinationModel(); - var wim = new List(); - foreach (var warehouse in await warehouseService.GetAllWarehouses()) - { - var pwiModel = new ProductAttributeCombinationModel.WarehouseInventoryModel { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name - }; - wim.Add(pwiModel); - } - - if (product.UseMultipleWarehouses) - { - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - } - - if (!string.IsNullOrEmpty(combinationId)) - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == combinationId); - if (combination != null) - { - model = combination.ToModel(); - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - model.ProductId = product.Id; - model.Attributes = await productAttributeFormatter.FormatAttributes(product, - combination.Attributes, contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false); - if (model.UseMultipleWarehouses) - foreach (var winv in combination.WarehouseInventory) - { - var warehouseInventoryModel = - model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == winv.WarehouseId); - if (warehouseInventoryModel != null) - { - warehouseInventoryModel.WarehouseUsed = true; - warehouseInventoryModel.Id = winv.Id; - warehouseInventoryModel.StockQuantity = winv.StockQuantity; - warehouseInventoryModel.ReservedQuantity = winv.ReservedQuantity; - } - } - } - } - - return model; - } - - public virtual async Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model) - { - var customAttributes = new List(); - var warnings = new List(); - - async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination combination) - { - var warehouses = await warehouseService.GetAllWarehouses(); - - foreach (var warehouse in warehouses) - { - var whim = model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - var existingPwI = combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (existingPwI != null) - { - if (whim is { WarehouseUsed: true }) - { - //update - existingPwI.StockQuantity = whim.StockQuantity; - existingPwI.ReservedQuantity = whim.ReservedQuantity; - } - else - { - //delete - combination.WarehouseInventory.Remove(existingPwI); - } - } - else - { - if (whim is { WarehouseUsed: true }) - { - //no need to insert a record for qty 0 - existingPwI = new ProductCombinationWarehouseInventory { - WarehouseId = whim.WarehouseId, - StockQuantity = whim.StockQuantity, - ReservedQuantity = whim.ReservedQuantity - }; - combination.WarehouseInventory.Add(existingPwI); - } - } - } - } - - if (string.IsNullOrEmpty(model.Id)) - { - #region Product attributes - - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - if (attributes.Count == 0) - { - warnings.Add("This combination attributes is empty!"); - return warnings; - } - - foreach (var attribute in attributes) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, ctrlAttributes).ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, item).ToList(); - } - break; - case AttributeControlType.ReadonlyCheckboxes: - { - //load read-only (already server-side selected) values - var attributeValues = attribute.ProductAttributeValues; - foreach (var selectedAttributeId in attributeValues - .Where(v => v.IsPreSelected) - .Select(v => v.Id) - .ToList()) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, selectedAttributeId).ToList(); - } - break; - } - - //validate conditional attributes (if specified) - foreach (var attribute in attributes) - { - var conditionMet = product.IsConditionMet(attribute, customAttributes); - if (conditionMet.HasValue && !conditionMet.Value) - customAttributes = ProductExtensions - .RemoveProductAttribute(customAttributes, attribute).ToList(); - } - - if (customAttributes.Count == 0) - { - warnings.Add("This combination custom attributes is empty!"); - return warnings; - } - - foreach (var customAttribute in customAttributes) - if (string.IsNullOrEmpty(customAttribute.Value)) - { - warnings.Add("Combination custom attributes need to be selected value!"); - return warnings; - } - - #endregion - - if (product.FindProductAttributeCombination(customAttributes) != null) - warnings.Add("This combination attributes exists!"); - - if (warnings.Count == 0) - { - var combination = new ProductAttributeCombination { - Attributes = customAttributes, - StockQuantity = model.StockQuantity, - ReservedQuantity = model.ReservedQuantity, - AllowOutOfStockOrders = model.AllowOutOfStockOrders, - Sku = model.Sku, - Text = model.Text, - Mpn = model.Mpn, - Gtin = model.Gtin, - OverriddenPrice = model.OverriddenPrice, - NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow, - PictureId = model.PictureId - }; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - combination.ReservedQuantity = combination.WarehouseInventory.Sum(x => x.ReservedQuantity); - } - - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - } - else - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.Id); - var prevCombination = (ProductAttributeCombination)combination!.Clone(); - - combination.StockQuantity = model.StockQuantity; - combination.ReservedQuantity = model.ReservedQuantity; - combination.AllowOutOfStockOrders = model.AllowOutOfStockOrders; - combination.Sku = model.Sku; - combination.Text = model.Text; - combination.Mpn = model.Mpn; - combination.Gtin = model.Gtin; - combination.OverriddenPrice = model.OverriddenPrice; - combination.NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow; - combination.PictureId = model.PictureId; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - } - - //notification - out of stock - await OutOfStockNotifications(product, combination, prevCombination); - - //update combination - await productAttributeService.UpdateProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await productService.GetProductById(model.ProductId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); - } - } - - return warnings; - } - - public virtual async Task GenerateAllAttributeCombinations(Product product) - { - var allAttributesComb = product.GenerateAllCombinations(); - if (allAttributesComb == null || allAttributesComb.Count == 0) - return; - - foreach (var attributes in allAttributesComb) - { - var customAttributes = attributes.ToList(); - if (!customAttributes.Any()) - continue; - - var existingCombination = product.FindProductAttributeCombination(customAttributes); - - //already exists? - if (existingCombination != null) - continue; - - //save combination - var combination = new ProductAttributeCombination { - Attributes = customAttributes.ToList(), - StockQuantity = 0, - AllowOutOfStockOrders = false, - Sku = null, - Mpn = null, - Gtin = null, - OverriddenPrice = null, - NotifyAdminForQuantityBelow = 1 - }; - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - } - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - - public virtual async Task ClearAllAttributeCombinations(Product product) - { - foreach (var combination in product.ProductAttributeCombinations) - await productAttributeService.DeleteProductAttributeCombination(combination, product.Id); - } - - public virtual Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId) - { - var items = new List(); - foreach (var x in product.ProductAttributeCombinations.Where(x => x.Id == productAttributeCombinationId) - .SelectMany(x => x.TierPrices)) - { - var priceModel = new ProductModel.ProductAttributeCombinationTierPricesModel { - Id = x.Id, - Price = x.Price, - Quantity = x.Quantity, - ProductId = product.Id, - ProductAttributeCombinationId = productAttributeCombinationId - }; - items.Add(priceModel); - } - - return Task.FromResult>(items); - } - - public virtual async Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (productAttributeCombination != null) - { - var pctp = new ProductCombinationTierPrices { - Price = model.Price, - Quantity = model.Quantity - }; - productAttributeCombination.TierPrices.Add(pctp); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, - product.Id); - } - } - - public virtual async Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (productAttributeCombination != null) - { - var tierPrice = productAttributeCombination.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice != null) - { - tierPrice.Price = model.Price; - tierPrice.Quantity = model.Quantity; - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, - product.Id); - } - } - } - - public virtual async Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice) - { - productAttributeCombination.TierPrices.Remove(tierPrice); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); - } - - //Pictures - public virtual async Task> PrepareProductPicturesModel(Product product) - { - var items = new List(); - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - { - var picture = await pictureService.GetPictureById(x.PictureId); - var m = new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - items.Add(m); - } - - return items; - } - - public virtual async Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel( - Product product, ProductPicture productPicture) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var model = new ProductModel.ProductPictureModel { - Id = productPicture.Id, - ProductId = product.Id, - PictureId = productPicture.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - - return (model, picture); - } - - public virtual async Task InsertProductPicture(Product product, Picture picture, int displayOrder) - { - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - if (product.ProductPictures.Count(x => x.PictureId == picture.Id) > 0) - return; - - await productService.InsertProductPicture(new ProductPicture { - PictureId = picture.Id, - DisplayOrder = displayOrder, - IsDefault = product.ProductPictures.Any() - }, product.Id); - - await pictureService.SetSeoFilename(picture, pictureService.GetPictureSeName(product.Name)); - } - - public virtual async Task UpdateProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var picture = await pictureService.GetPictureById(productPicture.PictureId); - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - productPicture.DisplayOrder = model.DisplayOrder; - productPicture.IsDefault = model.IsDefault; - await productService.UpdateProductPicture(productPicture, product.Id); - - //Update picture fields - await pictureService.UpdatePictureField(picture, x => x.AltAttribute, model.AltAttribute); - await pictureService.UpdatePictureField(picture, x => x.TitleAttribute, model.TitleAttribute); - await pictureService.UpdatePictureField(picture, x => x.Locales, model.Locales.ToTranslationProperty()); - await pictureService.UpdatePictureField(picture, x => x.Style, model.Style); - await pictureService.UpdatePictureField(picture, x => x.ExtraField, model.ExtraField); - } - - public virtual async Task DeleteProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var pictureId = productPicture.PictureId; - await productService.DeleteProductPicture(productPicture, product.Id); - - var picture = await pictureService.GetPictureById(pictureId); - if (picture != null) - await pictureService.DeletePicture(picture); - } - - //Product specification - public virtual async Task> PrepareProductSpecificationAttributeModel( - Product product) - { - var items = new List(); - foreach (var x in product.ProductSpecificationAttributes.OrderBy(x => x.DisplayOrder)) - { - var psaModel = new ProductSpecificationAttributeModel { - Id = x.Id, - AttributeTypeId = (int)x.AttributeTypeId, - AttributeId = x.SpecificationAttributeId, - ProductId = product.Id, - AttributeTypeName = enumTranslationService.GetTranslationEnum(x.AttributeTypeId), - AllowFiltering = x.AllowFiltering, - ShowOnProductPage = x.ShowOnProductPage, - DisplayOrder = x.DisplayOrder, - AttributeName = x.CustomName - }; - - switch (x.AttributeTypeId) - { - case SpecificationAttributeType.Option: - var specificationAttribute = - await specificationAttributeService.GetSpecificationAttributeById( - x.SpecificationAttributeId); - if (specificationAttribute != null) - { - psaModel.AttributeName = specificationAttribute.Name; - psaModel.ValueRaw = WebUtility.HtmlEncode(specificationAttribute - .SpecificationAttributeOptions - .FirstOrDefault(y => y.Id == x.SpecificationAttributeOptionId)?.Name); - } - - psaModel.SpecificationAttributeOptionId = x.SpecificationAttributeOptionId; - break; - case SpecificationAttributeType.CustomText: - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.CustomHtmlText: - //do not encode? - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.Hyperlink: - psaModel.ValueRaw = x.CustomValue; - break; - } - - items.Add(psaModel); - } - - return items; - } - - public virtual async Task InsertProductSpecificationAttributeModel( - ProductModel.AddProductSpecificationAttributeModel model, Product product) - { - //we allow filtering only for "Option" attribute type - if (model.AttributeTypeId != (int)SpecificationAttributeType.Option) - { - model.AllowFiltering = false; - model.SpecificationAttributeOptionId = null; - } - - var psa = model.ToEntity(); - - await specificationAttributeService.InsertProductSpecificationAttribute(psa, product.Id); - product.ProductSpecificationAttributes.Add(psa); - } - - public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model) - { - psa = model.ToEntity(psa); - await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); - } - - public virtual async Task DeleteProductSpecificationAttribute(Product product, - ProductSpecificationAttribute psa) - { - product.ProductSpecificationAttributes.Remove(psa); - await specificationAttributeService.DeleteProductSpecificationAttribute(psa, product.Id); - } - - protected virtual async Task UpdatePictureSeoNames(Product product) - { - var picturesename = pictureService.GetPictureSeName(product.Name); - foreach (var pp in product.ProductPictures) - { - var picture = await pictureService.GetPictureById(pp.PictureId); - if (picture != null) - await pictureService.SetSeoFilename(picture, picturesename); - } - } - - protected virtual async Task> GetChildCategoryIds(string parentCategoryId) - { - var categoriesIds = new List(); - var categories = await categoryService.GetAllCategoriesByParentCategoryId(parentCategoryId, true); - foreach (var category in categories) - { - categoriesIds.Add(category.Id); - categoriesIds.AddRange(await GetChildCategoryIds(category.Id)); - } - - return categoriesIds; - } - - protected virtual T PrepareAddProductModel() where T : ProductModel.AddProductModel, new() - { - var model = new T { - //product types - AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList() - }; - - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - return model; - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs index 92bbeb016..a0da106c1 100644 --- a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs @@ -12,7 +12,17 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config if (!DataSettingsManager.DatabaseIsInstalled()) return; - services.AddScoped(); + // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own + // StartupApplication via RoutedProductDataScope - see its doc comment. A plain + // AddScoped, VendorProductDataScope>() here used to win for every + // area under the combined Grand.Web host (Vendor's StartupApplication has the highest + // Priority, so its registration ran last and silently overrode Admin's/Store's), causing a + // NullReferenceException in VendorProductDataScope.DefaultVendorId whenever an Admin/Store + // user opened the product list under that host. + // IProductViewModelService is registered by Grand.Web.AdminShared's own StartupApplication + // (Grand.Web.AdminShared/Startup/StartupApplication.cs), which is discovered and run for this + // host too via the IStartupApplication assembly scan in StartupBase, since Vendor references + // AdminShared. Registering it again here would just be a redundant duplicate of that line. services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs deleted file mode 100644 index 6f8ec992e..000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class AddProductSpecificationAttributeModelValidator : BaseGrandValidator< - ProductModel.AddProductSpecificationAttributeModel> -{ - public AddProductSpecificationAttributeModelValidator( - IEnumerable> validators, - ITranslationService translationService, - ISpecificationAttributeService specificationAttributeService) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - if (x.AttributeTypeId == SpecificationAttributeType.Option) - { - if (string.IsNullOrEmpty(x.SpecificationAttributeId)) - return false; - if (string.IsNullOrEmpty(x.SpecificationAttributeOptionId)) - return false; - - var specification = - await specificationAttributeService.GetSpecificationAttributeById(x.SpecificationAttributeId); - - return specification?.SpecificationAttributeOptions.FirstOrDefault(z => - z.Id == x.SpecificationAttributeOptionId) != null; - } - - return !string.IsNullOrEmpty(x.CustomValue); - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.SpecificationAttributes.Validate")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs deleted file mode 100644 index 742983a33..000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Infrastructure; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class BundleProductModelValidator : BaseGrandValidator -{ - public BundleProductModelValidator( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - var product = await productService.GetProductById(x.ProductBundleId); - if (product == null) return true; - return product.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs deleted file mode 100644 index 24f055c90..000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs +++ /dev/null @@ -1,49 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductAttributeValueModelValidator : BaseGrandValidator -{ - public ProductAttributeValueModelValidator( - IEnumerable> validators, - ITranslationService translationService, IProductService productService) - : base(validators) - { - RuleFor(x => x.Name) - .NotEmpty() - .WithMessage(translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name.Required")); - - RuleFor(x => x.Quantity) - .GreaterThanOrEqualTo(1) - .WithMessage(translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Quantity.GreaterThanOrEqualTo1")) - .When(x => x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct); - - RuleFor(x => x).CustomAsync(async (x, context, _) => - { - var product = await productService.GetProductById(x.ProductId); - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(y => y.Id == x.ProductAttributeMappingId); - switch (productAttributeMapping?.AttributeControlTypeId) - { - case AttributeControlType.ColorSquares: - { - //ensure valid color is chosen/entered - if (string.IsNullOrEmpty(x.ColorSquaresRgb)) - context.AddFailure("Color is required"); - break; - } - //ensure a picture is uploaded - case AttributeControlType.ImageSquares when string.IsNullOrEmpty(x.ImageSquaresPictureId): - context.AddFailure("Image is required"); - break; - } - }); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs deleted file mode 100644 index 26f388870..000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Infrastructure; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductValidVendor : BaseGrandValidator -{ - public ProductValidVendor( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - var product = await productService.GetProductById(x.ProductId); - if (product == null) return true; - return product.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} - -public class ProductRelatedValidVendor : BaseGrandValidator -{ - public ProductRelatedValidVendor( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - //RelatedProductModel/SimilarProductModel actions only ever read and mutate ProductId1's - //mapping list, so ownership of ProductId1 is what must be enforced here. Accepting ownership - //of ProductId2 as an alternative (the previous "||") let a vendor who owns any product supply - //it as ProductId2 and edit/delete another vendor's ProductId1 mapping. - var product1 = await productService.GetProductById(x.ProductId1); - if (product1 == null) return true; - return product1.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs deleted file mode 100644 index e37529918..000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductValidator : BaseGrandValidator -{ - public ProductValidator( - IEnumerable> validators, - ITranslationService translationService, CommonSettings commonSettings) - : base(validators) - { - RuleFor(x => x.Name).NotEmpty() - .WithMessage(translationService.GetResource("Vendor.Catalog.Products.Fields.Name.Required")); - if (!commonSettings.AllowEditProductEndedAuction) - RuleFor(x => x.AuctionEnded && x.ProductTypeId == (int)ProductType.Auction).Equal(false) - .WithMessage(translationService.GetResource("Admin.Catalog.Products.Cannoteditauction")); - - RuleFor(x => x.ProductTypeId == (int)ProductType.Auction && !x.AvailableEndDateTime.HasValue) - .Equal(false) - .WithMessage( - translationService.GetResource("Vendor.Catalog.Products.Fields.AvailableEndDateTime.Required")); - } -} \ No newline at end of file