diff --git a/.gitignore b/.gitignore index faea5c658..36248a605 100644 --- a/.gitignore +++ b/.gitignore @@ -380,5 +380,6 @@ 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/*.**.worktrees/ +src/Web/Grand.Web.Store/wwwroot/assets/images/thumbs/*.* +.worktrees/ docs/superpowers/ diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs new file mode 100644 index 000000000..a356d767c --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCategoryControllerTests.cs @@ -0,0 +1,547 @@ +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Domain.Catalog; +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.Common; +using Grand.Web.Common.DataSource; +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; + +namespace Grand.Web.Admin.Tests.Controllers; + +// Characterization tests for the merged Category access-check behavior (ARCH-001 Category +// consolidation). Parameterized over a mocked IAdminDataScope instead of the two +// different concrete access mechanisms Admin (none) and Store (AccessToEntityByStore) used before. +[TestClass] +public class BaseCategoryControllerTests +{ + // BaseCategoryController is abstract; this minimal subclass exists only so actions under test + // can be invoked directly. No EditWarningCheck override here (Task 3 adds that on the real + // Store subclass) - the base's no-op default is exercised. + private class TestCategoryController( + ICategoryService categoryService, + ICategoryViewModelService categoryViewModelService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCategoryController(categoryService, categoryViewModelService, languageService, + translationService, pictureViewModelService, productService, scope); + + private TestCategoryController _controller; + private Mock _categoryServiceMock; + private Mock _categoryViewModelServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile()); + AutoMapperConfig.Init(mapperConfig); + + _categoryServiceMock = new Mock(); + _categoryViewModelServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _controller = new TestCategoryController( + _categoryServiceMock.Object, + _categoryViewModelServiceMock.Object, + languageServiceMock.Object, + _translationServiceMock.Object, + new Mock().Object, + new Mock().Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + 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); + } + + [TestMethod] + public async Task ListGet_PassesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _categoryViewModelServiceMock.Setup(v => v.PrepareCategoryListModel("store-1")).ReturnsAsync(new CategoryListModel()); + + var result = await _controller.List(); + + Assert.IsInstanceOfType(result, typeof(ViewResult)); + _categoryViewModelServiceMock.Verify(v => v.PrepareCategoryListModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task ListPost_ForcesScopeDefaultStoreIdOntoSearchModel() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _categoryViewModelServiceMock + .Setup(v => v.PrepareCategoryListModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new CategoryListModel { SearchStoreId = "attacker-supplied-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task ListPost_GlobalScope_LeavesSubmittedSearchStoreIdUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _categoryViewModelServiceMock + .Setup(v => v.PrepareCategoryListModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var model = new CategoryListModel { SearchStoreId = "admin-submitted-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("admin-submitted-store", model.SearchStoreId); + } + + // --- Edit (GET) -------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditGet_CategoryNotFound_RedirectsToList() + { + _categoryServiceMock.Setup(c => c.GetCategoryById("missing")).ReturnsAsync((Category)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 category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.CanView(category)).ReturnsAsync(false); + + var result = await _controller.Edit("c1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task EditGet_ScopeAllowsView_ReturnsViewWithModel() + { + var category = new Category { Id = "c1", Name = "Widgets" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.CanView(category)).ReturnsAsync(true); + var languageServiceMock = new Mock(); + _categoryViewModelServiceMock + .Setup(v => v.PrepareCategoryModel(It.IsAny(), category, null)) + .ReturnsAsync((CategoryModel m, Category c, string s) => m); + + var result = await _controller.Edit("c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreEqual("Widgets", ((CategoryModel)view.Model).Name); + } + + // --- Edit (POST) ------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditPost_ScopeDeniesAccess_RedirectsToEdit() + { + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(false); + + var result = await _controller.Edit(new CategoryModel { Id = "c1" }, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + _categoryViewModelServiceMock.Verify(v => v.UpdateCategoryModel(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- Delete -------------------------------------------------------------------------------------- + + [TestMethod] + public async Task Delete_ScopeDeniesAccess_RedirectsToEditWithoutDeleting() + { + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(false); + + var result = await _controller.Delete("c1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("c1", redirect.RouteValues["id"]); + _categoryViewModelServiceMock.Verify(v => v.DeleteCategory(It.IsAny()), Times.Never); + } + + // --- Create (POST) ------------------------------------------------------------------------------ + + [TestMethod] + public async Task CreatePost_StoreScoped_ForcesModelStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var inserted = new Category { Id = "new-1" }; + _categoryViewModelServiceMock + .Setup(v => v.InsertCategoryModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreSequenceEqual(new[] { "store-1" }, m.Stores)); + + await _controller.Create(new CategoryModel { Name = "N" }, false); + + _categoryViewModelServiceMock.Verify(v => v.InsertCategoryModel(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CreatePost_GlobalScoped_LeavesModelStoresUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var inserted = new Category { Id = "new-1" }; + var submitted = new CategoryModel { Name = "N", Stores = ["explicit-store"] }; + _categoryViewModelServiceMock + .Setup(v => v.InsertCategoryModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreSequenceEqual(new[] { "explicit-store" }, m.Stores)); + + await _controller.Create(submitted, false); + + _categoryViewModelServiceMock.Verify(v => v.InsertCategoryModel(It.IsAny()), Times.Once); + } + + // --- PicturePopup -------------------------------------------------------------------------------- + + [TestMethod] + public async Task PicturePopupGet_ScopeDeniesAccess_ReturnsDeniedContent() + { + var category = new Category { Id = "c1", PictureId = "pic-1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(false); + + var result = await _controller.PicturePopup("c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your category", content.Content); + } + + [TestMethod] + public async Task PicturePopupGet_CategoryHasNoPicture_ReturnsNotExistContent() + { + var category = new Category { Id = "c1", PictureId = null }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + + var result = await _controller.PicturePopup("c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Picture not exist", content.Content); + } + + [TestMethod] + public async Task PicturePopupGet_CategoryNotFound_ReturnsNotExistContent() + { + _categoryServiceMock.Setup(c => c.GetCategoryById("missing")).ReturnsAsync((Category)null); + + var result = await _controller.PicturePopup("missing"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Category not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PicturePopupPost_ScopeDeniesAccess_ReturnsDeniedContent() + { + var category = new Category { Id = "c1", PictureId = "pic-1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(false); + + var model = new PictureModel { ObjectId = "c1", Id = "pic-1" }; + var result = await _controller.PicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your category", content.Content); + } + + [TestMethod] + public async Task PicturePopupPost_CategoryNotFound_ThrowsArgumentException() + { + _categoryServiceMock.Setup(c => c.GetCategoryById("missing")).ReturnsAsync((Category)null); + + var model = new PictureModel { ObjectId = "missing", Id = "pic-1" }; + + var exception = await Assert.ThrowsExactlyAsync( + async () => await _controller.PicturePopup(model)); + + Assert.AreEqual("No category found with the specified id", exception.Message); + } + + [TestMethod] + public async Task PicturePopupPost_PictureIdMismatch_ThrowsArgumentException() + { + var category = new Category { Id = "c1", PictureId = "pic-1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + + var model = new PictureModel { ObjectId = "c1", Id = "pic-2" }; + + var exception = await Assert.ThrowsExactlyAsync( + async () => await _controller.PicturePopup(model)); + + Assert.AreEqual("Picture ident doesn't fit with category", exception.Message); + } + + [TestMethod] + public async Task PicturePopupPost_ValidRequest_CallsUpdatePicture() + { + var pictureViewModelServiceMock = new Mock(); + var category = new Category { Id = "c1", PictureId = "pic-1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + pictureViewModelServiceMock.Setup(p => p.UpdatePicture(It.IsAny())).Returns(Task.CompletedTask); + + var controller = new TestCategoryController( + _categoryServiceMock.Object, + _categoryViewModelServiceMock.Object, + new Mock().Object, + _translationServiceMock.Object, + pictureViewModelServiceMock.Object, + new Mock().Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + 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); + + var model = new PictureModel { ObjectId = "c1", Id = "pic-1" }; + + var result = await controller.PicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + pictureViewModelServiceMock.Verify(p => p.UpdatePicture(model), Times.Once); + } + + // --- Products tab --------------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductList_ScopeDeniesAccess_ReturnsKendoError() + { + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(false); + + var result = await _controller.ProductList(new DataSourceRequest { Page = 1, PageSize = 10 }, "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + } + + [TestMethod] + public async Task ProductUpdate_ProductNotOwnedByScopeStore_ReturnsKendoError() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var product = new Product { Id = "p1", LimitedToStores = true, Stores = ["other-store"] }; + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + + var controller = new TestCategoryController( + _categoryServiceMock.Object, _categoryViewModelServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.ProductUpdate(new CategoryModel.CategoryProductModel { Id = "pc1", ProductId = "p1" }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _categoryViewModelServiceMock.Verify(v => v.UpdateProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAddPopupInsert_GlobalScope_SkipsPerProductFiltering() + { + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var model = new CategoryModel.AddCategoryProductModel { CategoryId = "c1", SelectedProductIds = ["p1", "p2"] }; + + await _controller.ProductAddPopup(model); + + _categoryViewModelServiceMock.Verify(v => v.InsertCategoryProductModel( + It.Is(m => m.SelectedProductIds.Length == 2)), Times.Once); + } + + [TestMethod] + public async Task ProductAddPopupInsert_StoreScope_FiltersOutForeignProducts() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + + // owned-1 belongs to store-1 + var ownedProduct = new Product { Id = "owned-1", LimitedToStores = true, Stores = ["store-1"] }; + // foreign-1 belongs to other-store + var foreignProduct = new Product { Id = "foreign-1", LimitedToStores = true, Stores = ["other-store"] }; + + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("owned-1")).ReturnsAsync(ownedProduct); + productServiceMock.Setup(p => p.GetProductById("foreign-1")).ReturnsAsync(foreignProduct); + + var controller = new TestCategoryController( + _categoryServiceMock.Object, _categoryViewModelServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var model = new CategoryModel.AddCategoryProductModel { CategoryId = "c1", SelectedProductIds = ["owned-1", "foreign-1"] }; + + await controller.ProductAddPopup(model); + + _categoryViewModelServiceMock.Verify(v => v.InsertCategoryProductModel( + It.Is(m => m.SelectedProductIds.Length == 1 && m.SelectedProductIds[0] == "owned-1")), Times.Once); + } + + [TestMethod] + public async Task ProductAddPopupInsert_StoreScope_AllProductsForeign_SkipsInsertEntirely() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + + // both products belong to other stores + var foreignProduct1 = new Product { Id = "foreign-1", LimitedToStores = true, Stores = ["other-store"] }; + var foreignProduct2 = new Product { Id = "foreign-2", LimitedToStores = true, Stores = ["another-store"] }; + + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("foreign-1")).ReturnsAsync(foreignProduct1); + productServiceMock.Setup(p => p.GetProductById("foreign-2")).ReturnsAsync(foreignProduct2); + + var controller = new TestCategoryController( + _categoryServiceMock.Object, _categoryViewModelServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var model = new CategoryModel.AddCategoryProductModel { CategoryId = "c1", SelectedProductIds = ["foreign-1", "foreign-2"] }; + + await controller.ProductAddPopup(model); + + _categoryViewModelServiceMock.Verify(v => v.InsertCategoryProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAddPopupList_GlobalScope_LeavesSubmittedSearchStoreIdUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _categoryViewModelServiceMock + .Setup(v => v.PrepareProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new CategoryModel.AddCategoryProductModel { SearchStoreId = "admin-submitted-store" }; + await _controller.ProductAddPopupList(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("admin-submitted-store", model.SearchStoreId); + } + + [TestMethod] + public async Task ProductDelete_ProductNotOwnedByScopeStore_ReturnsKendoError() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var product = new Product { Id = "p1", LimitedToStores = true, Stores = ["other-store"] }; + var productServiceMock = new Mock(); + productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + + var controller = new TestCategoryController( + _categoryServiceMock.Object, _categoryViewModelServiceMock.Object, + new Mock().Object, _translationServiceMock.Object, + new Mock().Object, productServiceMock.Object, _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.ProductDelete(new CategoryModel.CategoryProductModel { Id = "pc1", ProductId = "p1" }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _categoryViewModelServiceMock.Verify(v => v.DeleteProductCategoryModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductList_ScopeGrantsAccess_ReturnsData() + { + var category = new Category { Id = "c1" }; + _categoryServiceMock.Setup(c => c.GetCategoryById("c1")).ReturnsAsync(category); + _scopeMock.Setup(s => s.HasAccess(category)).ReturnsAsync(true); + + var productModel = new CategoryModel.CategoryProductModel { Id = "cp1", ProductId = "p1", ProductName = "Product 1" }; + _categoryViewModelServiceMock + .Setup(v => v.PrepareCategoryProductModel("c1", 1, 10)) + .ReturnsAsync((new[] { productModel }, 1)); + + var result = await _controller.ProductList(new DataSourceRequest { Page = 1, PageSize = 10 }, "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNull(gridModel.Errors); + Assert.AreEqual(1, gridModel.Total); + Assert.IsNotNull(gridModel.Data); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCategoryDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCategoryDataScopeTests.cs new file mode 100644 index 000000000..7d9896257 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedCategoryDataScopeTests.cs @@ -0,0 +1,66 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedCategoryDataScopeTests +{ + private static RoutedCategoryDataScope Build(string area, out Mock> globalMock, out Mock> storeMock) + { + var httpContext = new DefaultHttpContext(); + if (area != null) + httpContext.Request.RouteValues = new RouteValueDictionary { ["area"] = area }; + + var httpContextAccessorMock = new Mock(); + httpContextAccessorMock.Setup(a => a.HttpContext).Returns(httpContext); + + var global = new GlobalAdminDataScope(); + var store = new StoreAdminDataScope(BuildContextAccessor()); + globalMock = null; + storeMock = null; + return new RoutedCategoryDataScope(httpContextAccessorMock.Object, global, store); + } + + private static Grand.Infrastructure.IContextAccessor BuildContextAccessor() + { + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(new Grand.Domain.Customers.Customer { StaffStoreId = "store-1" }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContext.Object); + return contextAccessorMock.Object; + } + + [TestMethod] + public void DefaultStoreId_AdminArea_ResolvesToGlobalScope() + { + var routed = Build("Admin", out _, out _); + Assert.IsNull(routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_StoreArea_ResolvesToStoreScope() + { + var routed = Build("Store", out _, out _); + Assert.AreEqual("store-1", routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_VendorArea_ThrowsFailClosed() + { + var routed = Build("Vendor", out _, out _); + Assert.ThrowsExactly(() => _ = routed.DefaultStoreId); + } + + [TestMethod] + public void DefaultStoreId_MissingArea_ThrowsFailClosed() + { + var routed = Build(null, out _, out _); + Assert.ThrowsExactly(() => _ = routed.DefaultStoreId); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml deleted file mode 100644 index 1f7a78b1b..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml +++ /dev/null @@ -1,80 +0,0 @@ -@model CategoryModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- - -
- - -} -else -{ -
- @Loc["Admin.Catalog.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabSeo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabSeo.cshtml deleted file mode 100644 index 6a82eda1b..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabSeo.cshtml +++ /dev/null @@ -1,77 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model CategoryModel - - - -@{ - Func template = @
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
-
; -} - - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 7b397fed3..000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model CategoryModel -@inject IPermissionService permissionService -@{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- @if (canManageDocuments) - { - - -
- -
-
-
- } - - -
- -
-
-
- -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..b55f963c6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..7204fcb9f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..244ac26fd --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..c3b270d27 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..9ddcdbb44 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml new file mode 100644 index 000000000..608780f95 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Top.cshtml new file mode 100644 index 000000000..f2d42e06e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Products.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..564e0e859 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..850b2162d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..463a9990d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Admin/Controllers/CategoryController.cs b/src/Web/Grand.Web.Admin/Controllers/CategoryController.cs index 9b962438d..baf24ce90 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CategoryController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CategoryController.cs @@ -1,350 +1,33 @@ -using Grand.Business.Core.Dto; -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.ExportImport; using Grand.Domain.Catalog; -using Grand.Domain.Permissions; -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.Common; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.Categories)] -public class CategoryController : BaseAdminController -{ - #region Constructors - - public CategoryController( - ICategoryService categoryService, - ICategoryViewModelService categoryViewModelService, - ILanguageService languageService, - ITranslationService translationService, - IPictureViewModelService pictureViewModelService) - { - _categoryService = categoryService; - _categoryViewModelService = categoryViewModelService; - _languageService = languageService; - _translationService = translationService; - _pictureViewModelService = pictureViewModelService; - } - - #endregion - - #region Fields - - private readonly ICategoryService _categoryService; - private readonly ICategoryViewModelService _categoryViewModelService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IPictureViewModelService _pictureViewModelService; - - #endregion - - #region List - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _categoryViewModelService.PrepareCategoryListModel(string.Empty); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, CategoryListModel model) - { - var categories = await _categoryViewModelService.PrepareCategoryListModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = categories.categoryListModel, - Total = categories.totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Create / Edit / Delete - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = await _categoryViewModelService.PrepareCategoryModel(string.Empty); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(CategoryModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var category = await _categoryViewModelService.InsertCategoryModel(model); - Success(_translationService.GetResource("Admin.Catalog.Categories.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = category.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model = await _categoryViewModelService.PrepareCategoryModel(model, null, string.Empty); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var category = await _categoryService.GetCategoryById(id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - var model = category.ToModel(); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = category.GetTranslation(x => x.Name, languageId, false); - locale.Description = category.GetTranslation(x => x.Description, languageId, false); - locale.BottomDescription = category.GetTranslation(x => x.BottomDescription, languageId, false); - locale.MetaKeywords = category.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = category.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = category.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = category.GetSeName(languageId, false); - locale.Flag = category.GetTranslation(x => x.Flag, languageId, false); - }); - model = await _categoryViewModelService.PrepareCategoryModel(model, category, string.Empty); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(CategoryModel model, bool continueEditing) - { - var category = await _categoryService.GetCategoryById(model.Id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - category = await _categoryViewModelService.UpdateCategoryModel(category, model); - Success(_translationService.GetResource("Admin.Catalog.Categories.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = category.Id }); - } - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model = await _categoryViewModelService.PrepareCategoryModel(model, category, string.Empty); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var category = await _categoryService.GetCategoryById(id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _categoryViewModelService.DeleteCategory(category); - Success(_translationService.GetResource("Admin.Catalog.Categories.Deleted")); - } - - return RedirectToAction("List"); - } - - #endregion - - #region Picture - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PicturePopup(string categoryId) - { - var category = await _categoryService.GetCategoryById(categoryId); - if (category == null) - return Content("Category not exist"); - - if (string.IsNullOrEmpty(category.PictureId)) - return Content("Picture not exist"); - - return View("Partials/PicturePopup", - await _pictureViewModelService.PreparePictureModel(category.PictureId, category.Id)); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PicturePopup(PictureModel model) - { - if (ModelState.IsValid) - { - var category = await _categoryService.GetCategoryById(model.ObjectId); - if (category == null) - throw new ArgumentException("No category found with the specified id"); - - if (string.IsNullOrEmpty(category.PictureId)) - throw new ArgumentException("No picture found with the specified id"); - - if (category.PictureId != model.Id) - throw new ArgumentException("Picture ident doesn't fit with category"); - - await _pictureViewModelService.UpdatePicture(model); - - return Content(""); - } - - Error(ModelState); - - return View("Partials/PicturePopup", model); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - public async Task ExportXlsx([FromServices] IExportManager exportManager) - { - try - { - var bytes = await exportManager.Export(await _categoryService.GetAllCategories(parentId: null, categoryName: "", storeId: "", showHidden: true)); - return File(bytes, "text/xls", "categories.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Import)] - [HttpPost] - public async Task ImportFromXlsx(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.Category.Imported")); - return RedirectToAction("List"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - #endregion - - #region Products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, string categoryId) - { - var category = await _categoryService.GetCategoryById(categoryId); - var productCategories = await _categoryViewModelService.PrepareCategoryProductModel(categoryId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productCategories.categoryProductModels, - Total = productCategories.totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductUpdate(CategoryModel.CategoryProductModel model) - { - if (ModelState.IsValid) - { - await _categoryViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductDelete(CategoryModel.CategoryProductModel model) - { - if (ModelState.IsValid) - { - await _categoryViewModelService.DeleteProductCategoryModel(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAddPopup(string categoryId) - { - var model = await _categoryViewModelService.PrepareAddCategoryProductModel(string.Empty); - model.CategoryId = categoryId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopupList(DataSourceRequest command, - CategoryModel.AddCategoryProductModel model) - { - var gridModel = new DataSourceResult(); - - var products = await _categoryViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - gridModel.Data = products.products.ToList(); - gridModel.Total = products.totalCount; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopup(CategoryModel.AddCategoryProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _categoryViewModelService.InsertCategoryProductModel(model); - - return Content(""); - } - - Error(ModelState); - return View(model); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseCategoryController (ARCH-001 Category consolidation). All +// 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 - BaseCategoryController +// can't inherit any single host's base controller (it's shared across Admin/Store, each with a +// different [Area]/[Authorize*] pair), so each subclass restates its own host's attribute set +// explicitly. Same pattern as ProductController (see that file). +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class CategoryController( + ICategoryService categoryService, + ICategoryViewModelService categoryViewModelService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCategoryController(categoryService, categoryViewModelService, languageService, + translationService, pictureViewModelService, productService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs new file mode 100644 index 000000000..255859132 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCategoryController.cs @@ -0,0 +1,373 @@ +using Grand.Business.Core.Dto; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Common; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +// [AutoValidateAntiforgeryToken] is restated on each concrete host subclass (Admin/Store +// CategoryController) too - ASP.NET Core resolves filters from the concrete controller's full type +// hierarchy at runtime, so every real endpoint is already protected. It's added here as well, +// mirroring BaseProductController, so static analysis that doesn't follow the attribute across a +// base/derived project boundary has something to see in the same file as the actions. +[PermissionAuthorize(PermissionSystemName.Categories)] +[AutoValidateAntiforgeryToken] +public abstract class BaseCategoryController( + ICategoryService categoryService, + ICategoryViewModelService categoryViewModelService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass (Task 3); no-op everywhere else. Mirrors + /// BaseProductController.EditWarningCheck. + protected virtual void EditWarningCheck(Category category) { } + + // Exposed for host subclasses: primary-constructor parameters are not visible to derived + // classes by name in C#, so Store's EditWarningCheck override needs this. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + + #region List + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = await categoryViewModelService.PrepareCategoryListModel(scope.DefaultStoreId); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task List(DataSourceRequest command, CategoryListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + var categories = await categoryViewModelService.PrepareCategoryListModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = categories.categoryListModel, + Total = categories.totalCount + }; + return Json(gridModel); + } + + #endregion + + #region Create / Edit / Delete + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = await categoryViewModelService.PrepareCategoryModel(scope.DefaultStoreId); + await AddLocales(languageService, model.Locales); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(CategoryModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + var category = await categoryViewModelService.InsertCategoryModel(model); + Success(translationService.GetResource("Admin.Catalog.Categories.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = category.Id }) : RedirectToAction("List"); + } + + model = await categoryViewModelService.PrepareCategoryModel(model, null, scope.DefaultStoreId); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var category = await categoryService.GetCategoryById(id); + if (category == null) return RedirectToAction("List"); + + EditWarningCheck(category); + // CanView, not HasAccess: viewing a shared/global category 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. + if (!await scope.CanView(category)) return RedirectToAction("List"); + + var model = category.ToModel(); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = category.GetTranslation(x => x.Name, languageId, false); + locale.Description = category.GetTranslation(x => x.Description, languageId, false); + locale.BottomDescription = category.GetTranslation(x => x.BottomDescription, languageId, false); + locale.MetaKeywords = category.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = category.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = category.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = category.GetSeName(languageId, false); + locale.Flag = category.GetTranslation(x => x.Flag, languageId, false); + }); + model = await categoryViewModelService.PrepareCategoryModel(model, category, scope.DefaultStoreId); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(CategoryModel model, bool continueEditing) + { + var category = await categoryService.GetCategoryById(model.Id); + if (category == null) return RedirectToAction("List"); + if (!await scope.HasAccess(category)) return RedirectToAction("Edit", new { id = category.Id }); + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + category = await categoryViewModelService.UpdateCategoryModel(category, model); + Success(translationService.GetResource("Admin.Catalog.Categories.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = category.Id }); + } + return RedirectToAction("List"); + } + + model = await categoryViewModelService.PrepareCategoryModel(model, category, scope.DefaultStoreId); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var category = await categoryService.GetCategoryById(id); + if (category == null) return RedirectToAction("List"); + if (!await scope.HasAccess(category)) return RedirectToAction("Edit", new { id = category.Id }); + + if (ModelState.IsValid) + { + await categoryViewModelService.DeleteCategory(category); + Success(translationService.GetResource("Admin.Catalog.Categories.Deleted")); + } + + return RedirectToAction("List"); + } + + #endregion + + #region Picture + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task PicturePopup(string categoryId) + { + var category = await categoryService.GetCategoryById(categoryId); + if (category == null) return Content("Category not exist"); + if (!await scope.HasAccess(category)) return Content("This is not your category"); + if (string.IsNullOrEmpty(category.PictureId)) return Content("Picture not exist"); + + return View("Partials/PicturePopup", + await pictureViewModelService.PreparePictureModel(category.PictureId, category.Id)); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task PicturePopup(PictureModel model) + { + if (ModelState.IsValid) + { + var category = await categoryService.GetCategoryById(model.ObjectId); + if (category == null) + throw new ArgumentException("No category found with the specified id"); + if (!await scope.HasAccess(category)) return Content("This is not your category"); + if (string.IsNullOrEmpty(category.PictureId)) + throw new ArgumentException("No picture found with the specified id"); + if (category.PictureId != model.Id) + throw new ArgumentException("Picture ident doesn't fit with category"); + + await pictureViewModelService.UpdatePicture(model); + return Content(""); + } + + Error(ModelState); + return View("Partials/PicturePopup", model); + } + + #endregion + + #region Export / Import + + [PermissionAuthorizeAction(PermissionActionName.Export)] + public async Task ExportXlsx([FromServices] IExportManager exportManager) + { + try + { + var bytes = await exportManager.Export(await categoryService.GetAllCategories(parentId: null, categoryName: "", storeId: scope.DefaultStoreId ?? "", showHidden: true)); + return File(bytes, "text/xls", "categories.xlsx"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Import)] + [HttpPost] + public async Task ImportFromXlsx(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.Category.Imported")); + return RedirectToAction("List"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + #endregion + + #region Products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, string categoryId) + { + var category = await categoryService.GetCategoryById(categoryId); + if (!await scope.HasAccess(category)) return ErrorForKendoGridJson("This is not your category"); + + var productCategories = await categoryViewModelService.PrepareCategoryProductModel(categoryId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = productCategories.categoryProductModels, + Total = productCategories.totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductUpdate(CategoryModel.CategoryProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(scope.DefaultStoreId)) + return ErrorForKendoGridJson("This is not your product"); + + if (ModelState.IsValid) + { + await categoryViewModelService.UpdateProductCategoryModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductDelete(CategoryModel.CategoryProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(scope.DefaultStoreId)) + return ErrorForKendoGridJson("This is not your product"); + + if (ModelState.IsValid) + { + await categoryViewModelService.DeleteProductCategoryModel(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAddPopup(string categoryId) + { + var model = await categoryViewModelService.PrepareAddCategoryProductModel(scope.DefaultStoreId); + model.CategoryId = categoryId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopupList(DataSourceRequest command, CategoryModel.AddCategoryProductModel model) + { + var gridModel = new DataSourceResult(); + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + var products = await categoryViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + gridModel.Data = products.products.ToList(); + gridModel.Total = products.totalCount; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopup(CategoryModel.AddCategoryProductModel model) + { + var category = await categoryService.GetCategoryById(model.CategoryId); + if (category == null || !await scope.HasAccess(category)) + return Content("This is not your category"); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) + { + if (scope.DefaultStoreId is null) + { + // Global scope (Admin): no per-product ownership concept, insert as submitted - + // matches Admin's original, unfiltered behavior exactly. + await categoryViewModelService.InsertCategoryProductModel(model); + } + else + { + // Store scope: InsertCategoryProductModel mutates each selected product's + // ProductCategories collection, so every selected id must also belong to the + // current store - matches Store's original filtering loop exactly. + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (selected != null && selected.AccessToEntityByStore(scope.DefaultStoreId)) + validIds.Add(id); + } + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await categoryViewModelService.InsertCategoryProductModel(model); + } + } + + return Content(""); + } + + Error(ModelState); + return View(model); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedCategoryDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedCategoryDataScope.cs new file mode 100644 index 000000000..4aaae71dc --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedCategoryDataScope.cs @@ -0,0 +1,55 @@ +#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 — same fix and same reason +/// as (see that file's doc comment): Grand.Web (the +/// combined host) loads Admin and Store together in one DI container, so a plain +/// AddScoped<IAdminDataScope<Category>, X>() per host would silently let whichever +/// host's StartupApplication ran last win for every area in that process. +/// +/// Unlike Product, there is no Vendor branch: Vendor has no Category screen at all, so any +/// "Vendor" (or other unrecognized/missing) area value fails closed. +/// +public class RoutedCategoryDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreAdminDataScope storeScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => globalScope, + "Store" => storeScope, + //fail closed: this object fronts store tenant isolation, so an unrecognized or + //missing area (including "Vendor" - Category has no Vendor screen) must never + //silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedCategoryDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(Category entity) => Resolved.HasAccess(entity); + + public Task CanView(Category 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/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 51937c430..f2a319048 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -70,6 +70,12 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped>(); services.AddScoped(); services.AddScoped, RoutedProductDataScope>(); + + // IAdminDataScope: registered once here for the same reason as Product above — see + // RoutedCategoryDataScope's doc comment. No Vendor scope: Category has no Vendor screen. + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped, RoutedCategoryDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Create.cshtml similarity index 84% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Create.cshtml index 3a15acc5f..03847cabb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Create.cshtml @@ -1,9 +1,10 @@ -@model CategoryModel +@model CategoryModel @{ //page title ViewBag.Title = Loc["Admin.Catalog.Categories.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -27,11 +28,11 @@
- +
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Edit.cshtml similarity index 86% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Edit.cshtml index 5a3ac9167..3aa5a50cb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Edit.cshtml @@ -1,9 +1,10 @@ -@model CategoryModel +@model CategoryModel @{ //page title ViewBag.Title = Loc["Admin.Catalog.Categories.EditCategoryDetails"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -34,7 +35,7 @@
- +
@@ -42,4 +43,4 @@ - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml index 26bfeff75..4d67ed60a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDiscounts.cshtml @@ -1,5 +1,5 @@ -@model CategoryModel - +@model CategoryModel + @if (Model.AvailableDiscounts is { Count: > 0 }) {
@@ -22,4 +22,4 @@ else @Html.Raw(Loc["Admin.Catalog.Categories.Discounts.NoDiscounts"])
} - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDocuments.cshtml similarity index 81% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDocuments.cshtml index 9c0d13167..dc38ca98d 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/CreateOrUpdate.TabDocuments.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Category/Partials/CreateOrUpdate.TabDocuments.cshtml @@ -1,16 +1,19 @@ -@model CategoryModel +@model CategoryModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- +
- -} -else -{ -
- @Loc["Admin.Catalog.Categories.Products.SaveBeforeEdit"] -
-} - \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 000000000..ea0c11c16 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 000000000..9f4d46fd4 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 000000000..4bdc29fec --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 000000000..f8bf8bcf6 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 000000000..2ec4d8f3f --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml new file mode 100644 index 000000000..efd2eb8fa --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Top.cshtml new file mode 100644 index 000000000..55ad78c64 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Products.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 000000000..1cda494f1 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 000000000..1ed2c1997 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 000000000..307a7fb11 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Category/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1,2 @@ +@model CategoryModel + diff --git a/src/Web/Grand.Web.Store/Controllers/CategoryController.cs b/src/Web/Grand.Web.Store/Controllers/CategoryController.cs index 22d220ba4..adeec3876 100644 --- a/src/Web/Grand.Web.Store/Controllers/CategoryController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CategoryController.cs @@ -1,366 +1,46 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Categories; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.Web.AdminShared.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Common; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.Categories)] -public class CategoryController : BaseStoreController +// Reduced to a thin subclass of BaseCategoryController (ARCH-001 Category consolidation). All +// 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. Same pattern as ProductController (see that file). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class CategoryController( + ICategoryService categoryService, + ICategoryViewModelService categoryViewModelService, + ILanguageService languageService, + ITranslationService translationService, + IPictureViewModelService pictureViewModelService, + IProductService productService, + IAdminDataScope scope) + : BaseCategoryController(categoryService, categoryViewModelService, languageService, + translationService, pictureViewModelService, productService, scope) { - #region Constructors - - public CategoryController( - ICategoryService categoryService, - ICategoryViewModelService categoryViewModelService, - ILanguageService languageService, - ITranslationService translationService, - IContextAccessor contextAccessor, - IPictureViewModelService pictureViewModelService, - IProductService productService) - { - _categoryService = categoryService; - _categoryViewModelService = categoryViewModelService; - _languageService = languageService; - _translationService = translationService; - _contextAccessor = contextAccessor; - _pictureViewModelService = pictureViewModelService; - _productService = productService; - } - - #endregion - - #region Fields - - private readonly ICategoryService _categoryService; - private readonly ICategoryViewModelService _categoryViewModelService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; - private readonly IPictureViewModelService _pictureViewModelService; - private readonly IProductService _productService; - - #endregion - - #region List - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _categoryViewModelService.PrepareCategoryListModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, CategoryListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var categories = await _categoryViewModelService.PrepareCategoryListModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = categories.categoryListModel, - Total = categories.totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Create / Edit / Delete - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = await _categoryViewModelService.PrepareCategoryModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(CategoryModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - var category = await _categoryViewModelService.InsertCategoryModel(model); - Success(_translationService.GetResource("Admin.Catalog.Categories.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = category.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model = await _categoryViewModelService.PrepareCategoryModel(model, null, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var category = await _categoryService.GetCategoryById(id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - if (!category.LimitedToStores || (category.LimitedToStores && - category.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && - category.Stores.Count > 1)) - { - Warning(_translationService.GetResource("Admin.Catalog.Categories.Permissions")); - } - else - { - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - } - - var model = category.ToModel(); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = category.GetTranslation(x => x.Name, languageId, false); - locale.Description = category.GetTranslation(x => x.Description, languageId, false); - locale.BottomDescription = category.GetTranslation(x => x.BottomDescription, languageId, false); - locale.MetaKeywords = category.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = category.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = category.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = category.GetSeName(languageId, false); - locale.Flag = category.GetTranslation(x => x.Flag, languageId, false); - }); - - model = await _categoryViewModelService.PrepareCategoryModel(model, category, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(CategoryModel model, bool continueEditing) - { - var category = await _categoryService.GetCategoryById(model.Id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("Edit", new { id = category.Id }); - - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - category = await _categoryViewModelService.UpdateCategoryModel(category, model); - - Success(_translationService.GetResource("Admin.Catalog.Categories.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = category.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model = await _categoryViewModelService.PrepareCategoryModel(model, category, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var category = await _categoryService.GetCategoryById(id); - if (category == null) - //No category found with the specified id - return RedirectToAction("List"); - - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("Edit", new { id = category.Id }); - - if (ModelState.IsValid) - { - await _categoryViewModelService.DeleteCategory(category); - Success(_translationService.GetResource("Admin.Catalog.Categories.Deleted")); - } - - return RedirectToAction("List"); - } - - #endregion - - #region Picture - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task PicturePopup(string categoryId) - { - var category = await _categoryService.GetCategoryById(categoryId); - if (category == null) - return Content("Category not exist"); - - if (string.IsNullOrEmpty(category.PictureId)) - return Content("Picture not exist"); - - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your category"); - - return View("Partials/PicturePopup", - await _pictureViewModelService.PreparePictureModel(category.PictureId, category.Id)); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task PicturePopup(PictureModel model) - { - if (ModelState.IsValid) - { - var category = await _categoryService.GetCategoryById(model.ObjectId); - if (category == null) - throw new ArgumentException("No category found with the specified id"); - - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your category"); - - if (string.IsNullOrEmpty(category.PictureId)) - throw new ArgumentException("No picture found with the specified id"); - - if (category.PictureId != model.Id) - throw new ArgumentException("Picture ident doesn't fit with category"); - - await _pictureViewModelService.UpdatePicture(model); - - return Content(""); - } - - Error(ModelState); - - return View("Partials/PicturePopup", model); - } - - #endregion - - #region Products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, string categoryId) - { - var category = await _categoryService.GetCategoryById(categoryId); - - if (!category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your category"); - - var productCategories = await _categoryViewModelService.PrepareCategoryProductModel(categoryId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productCategories.categoryProductModels, - Total = productCategories.totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductUpdate(CategoryModel.CategoryProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your product"); - - if (ModelState.IsValid) - { - await _categoryViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductDelete(CategoryModel.CategoryProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return ErrorForKendoGridJson("This is not your product"); - - if (ModelState.IsValid) - { - await _categoryViewModelService.DeleteProductCategoryModel(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAddPopup(string categoryId) - { - var model = await _categoryViewModelService.PrepareAddCategoryProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - model.CategoryId = categoryId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopupList(DataSourceRequest command, CategoryModel.AddCategoryProductModel model) - { - var gridModel = new DataSourceResult(); - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var products = await _categoryViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - gridModel.Data = products.products.ToList(); - gridModel.Total = products.totalCount; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAddPopup(CategoryModel.AddCategoryProductModel model) - { - var category = await _categoryService.GetCategoryById(model.CategoryId); - if (category == null || !category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return Content("This is not your category"); - - if (ModelState.IsValid) - { - //InsertCategoryProductModel mutates each selected product's ProductCategories collection, - //so every selected id must also belong to the current store. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await _productService.GetProductById(id); - if (selected != null && selected.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - validIds.Add(id); - } - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Any()) await _categoryViewModelService.InsertCategoryProductModel(model); - } - - return Content(""); - } - - Error(ModelState); - return View(model); - } - - #endregion -} \ No newline at end of file + // Re-derived from the original Store CategoryController.Edit(GET) (pre-cutover: + // src/Web/Grand.Web.Store/Controllers/CategoryController.cs, lines ~122-132) - 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). + protected override void EditWarningCheck(Category category) + { + if (!category.LimitedToStores || + (category.LimitedToStores && + category.Stores.Contains(Scope.DefaultStoreId) && + category.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Catalog.Categories.Permissions")); + } +}