diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs new file mode 100644 index 0000000000..f1834e997a --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseNewsControllerTests.cs @@ -0,0 +1,421 @@ +using Grand.Business.Core.Interfaces.Cms; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.News; +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.News; +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 News access-check behavior (ARCH-001 News consolidation). +[TestClass] +public class BaseNewsControllerTests +{ + private class TestNewsController( + INewsViewModelService newsViewModelService, + INewsService newsService, + ILanguageService languageService, + ITranslationService translationService, + IStoreService storeService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseNewsController(newsViewModelService, newsService, languageService, translationService, + storeService, dateTimeService, scope); + + private TestNewsController _controller; + private Mock _newsViewModelServiceMock; + private Mock _newsServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile()); + AutoMapperConfig.Init(mapperConfig); + + _newsViewModelServiceMock = new Mock(); + _newsServiceMock = 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()); + + var storeServiceMock = new Mock(); + storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + + _controller = new TestNewsController( + _newsViewModelServiceMock.Object, + _newsServiceMock.Object, + languageServiceMock.Object, + _translationServiceMock.Object, + storeServiceMock.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 ListPost_UsesSharedPrepareNewsItemModel_NotGetAllNewsDirectly() + { + _newsViewModelServiceMock + .Setup(v => v.PrepareNewsItemModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, new NewsItemListModel()); + + _newsViewModelServiceMock.Verify(v => v.PrepareNewsItemModel(It.IsAny(), 1, 10), Times.Once); + _newsServiceMock.Verify(s => s.GetAllNews(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListPost_ForcesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _newsViewModelServiceMock + .Setup(v => v.PrepareNewsItemModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new NewsItemListModel { SearchStoreId = "attacker-supplied" }; + 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); + _newsViewModelServiceMock + .Setup(v => v.PrepareNewsItemModel(It.IsAny(), 1, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new NewsItemListModel { SearchStoreId = "admin-submitted-store" }; + await _controller.List(new DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("admin-submitted-store", model.SearchStoreId); + } + + [TestMethod] + public async Task ListGet_GlobalScope_PopulatesAvailableStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var storeServiceMock = new Mock(); + storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync( + new List { new() { Id = "s1", Shortcut = "Store One" } }); + var controller = new TestNewsController( + _newsViewModelServiceMock.Object, _newsServiceMock.Object, new Mock().Object, + _translationServiceMock.Object, storeServiceMock.Object, new Mock().Object, + _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.List(); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = (NewsItemListModel)view.Model; + Assert.IsTrue(model.AvailableStores.Any(s => s.Value == "s1")); + } + + [TestMethod] + public async Task ListGet_StoreScoped_LeavesAvailableStoresEmpty() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var storeServiceMock = new Mock(); + var controller = new TestNewsController( + _newsViewModelServiceMock.Object, _newsServiceMock.Object, new Mock().Object, + _translationServiceMock.Object, storeServiceMock.Object, new Mock().Object, + _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + var result = await controller.List(); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = (NewsItemListModel)view.Model; + Assert.AreEqual(0, model.AvailableStores.Count); + storeServiceMock.Verify(s => s.GetAllStores(), Times.Never); + } + + [TestMethod] + public async Task CreatePost_InsertsOnce_NeverCallsUpdateNews() + { + var inserted = new NewsItem { Id = "new-1" }; + _newsViewModelServiceMock + .Setup(v => v.InsertNewsItemModel(It.IsAny())) + .ReturnsAsync(inserted); + + await _controller.Create(new NewsItemModel { Title = "N" }, false); + + _newsViewModelServiceMock.Verify(v => v.InsertNewsItemModel(It.IsAny()), Times.Once); + _newsServiceMock.Verify(s => s.UpdateNews(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CreatePost_StoreScoped_ForcesModelStores() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var inserted = new NewsItem { Id = "new-1" }; + _newsViewModelServiceMock + .Setup(v => v.InsertNewsItemModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreEqual("store-1", m.Stores.Single())); + + await _controller.Create(new NewsItemModel { Title = "N" }, false); + + _newsViewModelServiceMock.Verify(v => v.InsertNewsItemModel(It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task CreatePost_GlobalScoped_LeavesModelStoresUntouched() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var inserted = new NewsItem { Id = "new-1" }; + var submitted = new NewsItemModel { Title = "N", Stores = ["explicit-store"] }; + _newsViewModelServiceMock + .Setup(v => v.InsertNewsItemModel(It.IsAny())) + .ReturnsAsync(inserted) + .Callback(m => Assert.AreEqual("explicit-store", m.Stores.Single())); + + await _controller.Create(submitted, false); + } + + [TestMethod] + public async Task CreateGet_PopulatesAllLanguages() + { + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List { new() { Id = "l1" } }); + var controller = new TestNewsController( + _newsViewModelServiceMock.Object, _newsServiceMock.Object, languageServiceMock.Object, + _translationServiceMock.Object, new Mock().Object, new Mock().Object, + _scopeMock.Object); + controller.ControllerContext = _controller.ControllerContext; + controller.TempData = _controller.TempData; + + await controller.Create(); + + // Confirms the value assigned to ViewBag.AllLanguages is the resolved list, not an unawaited + // Task object (the pre-existing bug this task fixes) - would throw InvalidCastException or + // fail this assertion if the bug were reintroduced. + var allLanguages = (IList)controller.ViewBag.AllLanguages; + Assert.AreEqual(1, allLanguages.Count); + } + + [TestMethod] + public async Task EditGet_NewsItemNotFound_RedirectsToList() + { + _newsServiceMock.Setup(n => n.GetNewsById("missing")).ReturnsAsync((NewsItem)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 newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(false); + + var result = await _controller.Edit("n1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task EditPost_ScopeDeniesAccess_RedirectsToEdit() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(false); + + var result = await _controller.Edit(new NewsItemModel { Id = "n1" }, false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + _newsViewModelServiceMock.Verify(v => v.UpdateNewsItemModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeDeniesAccess_RedirectsToListWithoutDeleting() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(false); + + var result = await _controller.Delete("n1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _newsServiceMock.Verify(n => n.DeleteNews(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeGrantsAccess_Deletes() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(true); + + var result = await _controller.Delete("n1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _newsServiceMock.Verify(n => n.DeleteNews(newsItem), Times.Once); + } + + [TestMethod] + public void Comments_RedirectsToList() + { + // Comments(string) has no Comments.cshtml to render (unlike Blog, which has one) and is + // unreachable from the UI - the Comments tab's grid calls the POST overload only. Redirects + // instead of rendering a nonexistent view; see BaseNewsController's ruled fix comment. + var result = _controller.Comments("n1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task Comments_ScopeDeniesView_ReturnsKendoErrorWithoutCallingPrepareNewsCommentModel() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(false); + + var result = await _controller.Comments("n1", new DataSourceRequest { Page = 1, PageSize = 10 }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _newsViewModelServiceMock.Verify( + v => v.PrepareNewsCommentModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Comments_NewsItemNotFound_ReturnsKendoErrorWithoutCallingPrepareNewsCommentModel() + { + _newsServiceMock.Setup(n => n.GetNewsById("missing")).ReturnsAsync((NewsItem)null); + + var result = await _controller.Comments("missing", new DataSourceRequest { Page = 1, PageSize = 10 }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _newsViewModelServiceMock.Verify( + v => v.PrepareNewsCommentModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Comments_ScopeGrantsView_ReturnsCommentsGrid() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(true); + var commentModels = new List { new() { Id = "c1" } }; + _newsViewModelServiceMock + .Setup(v => v.PrepareNewsCommentModel("n1", 1, 10)) + .ReturnsAsync((commentModels, 1)); + + var result = await _controller.Comments("n1", new DataSourceRequest { Page = 1, PageSize = 10 }); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNull(gridModel.Errors); + Assert.AreEqual(1, gridModel.Total); + _newsViewModelServiceMock.Verify(v => v.PrepareNewsCommentModel("n1", 1, 10), Times.Once); + } + + [TestMethod] + public async Task CommentDelete_ScopeDeniesAccess_ReturnsKendoErrorWithoutDeleting() + { + var comment = new NewsComment { Id = "c1", NewsItemId = "n1" }; + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(false); + + var result = await _controller.CommentDelete(comment); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _newsViewModelServiceMock.Verify(v => v.CommentDelete(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CommentDelete_ScopeGrantsAccess_Deletes() + { + var comment = new NewsComment { Id = "c1", NewsItemId = "n1" }; + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(true); + + var result = await _controller.CommentDelete(comment); + + var json = result as JsonResult; + Assert.IsNotNull(json); + _newsViewModelServiceMock.Verify(v => v.CommentDelete(comment), Times.Once); + } + + [TestMethod] + public async Task CommentDelete_NewsItemNotFound_ReturnsKendoErrorWithoutDeleting() + { + var comment = new NewsComment { Id = "c1", NewsItemId = "missing" }; + _newsServiceMock.Setup(n => n.GetNewsById("missing")).ReturnsAsync((NewsItem)null); + + var result = await _controller.CommentDelete(comment); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = (DataSourceResult)json.Value; + Assert.IsNotNull(gridModel.Errors); + _newsViewModelServiceMock.Verify(v => v.CommentDelete(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/NewsControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/NewsControllerTests.cs new file mode 100644 index 0000000000..20e8de8b7a --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/NewsControllerTests.cs @@ -0,0 +1,35 @@ +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Reflection; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class NewsControllerTests +{ + [TestMethod] + public void NewsController_IsThinSubclassOfBaseNewsController() + { + Assert.IsTrue(typeof(BaseNewsController).IsAssignableFrom(typeof(NewsController))); + Assert.AreNotEqual(typeof(BaseNewsController), typeof(NewsController)); + } + + [TestMethod] + public void NewsController_HasRequiredHostAttributes() + { + var type = typeof(NewsController); + + var areaAttr = type.GetCustomAttribute(inherit: false); + Assert.IsNotNull(areaAttr); + Assert.AreEqual("Admin", areaAttr.RouteValue); + + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + // Confirm the actual namespace of AuthorizeAdminAttribute by reading the file that declares it + // before writing this assertion - Phase 15 found it lives in Grand.Web.Common.Filters, not + // Grand.Web.Admin.Extensions as might be assumed. + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedNewsItemDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedNewsItemDataScopeTests.cs new file mode 100644 index 0000000000..8e07894777 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedNewsItemDataScopeTests.cs @@ -0,0 +1,66 @@ +using Grand.Domain.News; +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 RoutedNewsItemDataScopeTests +{ + private static RoutedNewsItemDataScope 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 RoutedNewsItemDataScope(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/Tests/Grand.Web.Store.Tests/Controllers/NewsControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/NewsControllerTests.cs new file mode 100644 index 0000000000..f3e230887c --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/NewsControllerTests.cs @@ -0,0 +1,168 @@ +using Grand.Business.Core.Interfaces.Cms; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.News; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.Store.Controllers; +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.Reflection; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class NewsControllerTests +{ + private NewsController _controller; + private Mock _newsServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile()); + AutoMapperConfig.Init(mapperConfig); + + _newsServiceMock = new Mock(); + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List()); + + _controller = new NewsController( + new Mock().Object, + _newsServiceMock.Object, + languageServiceMock.Object, + new Mock().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 void NewsController_HasRequiredHostAttributes() + { + var type = typeof(NewsController); + + var areaAttr = type.GetCustomAttribute(inherit: false); + Assert.IsNotNull(areaAttr); + Assert.AreEqual("Store", areaAttr.RouteValue); + + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + Assert.IsNotNull(type.GetCustomAttribute(inherit: false)); + } + + // --- EditWarningCheck truth table ----------------------------------------------------------- + + [TestMethod] + public async Task EditGet_NotLimitedToStores_WarningFires() + { + var newsItem = new NewsItem { Id = "n1", LimitedToStores = false }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(true); + + await _controller.Edit("n1"); + + Assert.IsTrue(_controller.TempData.ContainsKey("grand.notifications.Warning")); + } + + [TestMethod] + public async Task EditGet_LimitedContainsStore_CountGreaterThanOne_WarningFires() + { + var newsItem = new NewsItem { Id = "n1", LimitedToStores = true, Stores = ["store-1", "store-2"] }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(true); + + await _controller.Edit("n1"); + + Assert.IsTrue(_controller.TempData.ContainsKey("grand.notifications.Warning")); + } + + [TestMethod] + public async Task EditGet_LimitedContainsStore_CountOne_NoWarning() + { + var newsItem = new NewsItem { Id = "n1", LimitedToStores = true, Stores = ["store-1"] }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(true); + + await _controller.Edit("n1"); + + Assert.IsFalse(_controller.TempData.ContainsKey("grand.notifications.Warning")); + } + + [TestMethod] + public async Task EditGet_LimitedDoesNotContainStore_NoWarning() + { + var newsItem = new NewsItem { Id = "n1", LimitedToStores = true, Stores = ["other-store"] }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.CanView(newsItem)).ReturnsAsync(true); + + await _controller.Edit("n1"); + + Assert.IsFalse(_controller.TempData.ContainsKey("grand.notifications.Warning")); + } + + // --- Preview ------------------------------------------------------------------------------------- + + [TestMethod] + public async Task Preview_NewsItemNotFound_RedirectsToList() + { + _newsServiceMock.Setup(n => n.GetNewsById("missing")).ReturnsAsync((NewsItem)null); + + var result = await _controller.Preview("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task Preview_ScopeDeniesAccess_RedirectsToList() + { + var newsItem = new NewsItem { Id = "n1" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(false); + + var result = await _controller.Preview("n1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task Preview_ScopeGrantsAccess_ReturnsViewWithModel() + { + var newsItem = new NewsItem { Id = "n1", Title = "Hello" }; + _newsServiceMock.Setup(n => n.GetNewsById("n1")).ReturnsAsync(newsItem); + _scopeMock.Setup(s => s.HasAccess(newsItem)).ReturnsAsync(true); + + var result = await _controller.Preview("n1"); + + Assert.IsInstanceOfType(result, typeof(ViewResult)); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabComments.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabComments.cshtml deleted file mode 100644 index 9066a7a6f2..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabComments.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model NewsItemModel -@inject AdminAreaSettings adminAreaSettings - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- -
- -} -else -{ -
- @Loc["Admin.Content.News.NewsItems.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabSeo.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabSeo.cshtml deleted file mode 100644 index 2f504cbfe6..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/CreateOrUpdate.TabSeo.cshtml +++ /dev/null @@ -1,75 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model NewsItemModel - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml new file mode 100644 index 0000000000..f2c9454117 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml new file mode 100644 index 0000000000..30b8c212b6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml new file mode 100644 index 0000000000..7c73e3b30a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml new file mode 100644 index 0000000000..298c7eebd4 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml new file mode 100644 index 0000000000..de6d83eb1e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml new file mode 100644 index 0000000000..527b143a4c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml new file mode 100644 index 0000000000..45c7bec508 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml new file mode 100644 index 0000000000..4dc77016ad --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml new file mode 100644 index 0000000000..87f8b379a3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 0000000000..ff3ba03b40 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 0000000000..db14baf131 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Controllers/NewsController.cs b/src/Web/Grand.Web.Admin/Controllers/NewsController.cs index e84b124b78..82f8b9a8c9 100644 --- a/src/Web/Grand.Web.Admin/Controllers/NewsController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/NewsController.cs @@ -1,229 +1,32 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Cms; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Domain.Permissions; using Grand.Domain.News; -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.News; -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; -using Microsoft.AspNetCore.Mvc.Rendering; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.News)] -public class NewsController : BaseAdminController -{ - #region Constructors - - public NewsController( - INewsViewModelService newsViewModelService, - INewsService newsService, - ILanguageService languageService, - ITranslationService translationService, - IStoreService storeService, - IDateTimeService dateTimeService) - { - _newsViewModelService = newsViewModelService; - _newsService = newsService; - _languageService = languageService; - _translationService = translationService; - _storeService = storeService; - _dateTimeService = dateTimeService; - } - - #endregion - - #region Fields - - private readonly INewsViewModelService _newsViewModelService; - private readonly INewsService _newsService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IStoreService _storeService; - private readonly IDateTimeService _dateTimeService; - - #endregion - - #region News items - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = new NewsItemListModel(); - //stores - 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 }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, NewsItemListModel model) - { - var news = await _newsViewModelService.PrepareNewsItemModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = news.newsItemModels.ToList(), - Total = news.totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - ViewBag.AllLanguages = _languageService.GetAllLanguages(true); - var model = new NewsItemModel { - //default values - Published = true, - AllowComments = true - }; - - //locales - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(NewsItemModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var newsItem = await _newsViewModelService.InsertNewsItemModel(model); - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = newsItem.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = _languageService.GetAllLanguages(true); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var newsItem = await _newsService.GetNewsById(id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); - var model = newsItem.ToModel(_dateTimeService); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Title = newsItem.GetTranslation(x => x.Title, languageId, false); - locale.Short = newsItem.GetTranslation(x => x.Short, languageId, false); - locale.Full = newsItem.GetTranslation(x => x.Full, languageId, false); - locale.MetaKeywords = newsItem.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = newsItem.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = newsItem.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = newsItem.GetSeName(languageId, false); - }); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(NewsItemModel model, bool continueEditing) - { - var newsItem = await _newsService.GetNewsById(model.Id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - newsItem = await _newsViewModelService.UpdateNewsItemModel(newsItem, model); - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = newsItem.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var newsItem = await _newsService.GetNewsById(id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - if (ModelState.IsValid) - { - await _newsService.DeleteNews(newsItem); - - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = newsItem.Id }); - } - - #endregion - - #region Comments - - public IActionResult Comments(string filterByNewsItemId) - { - ViewBag.FilterByNewsItemId = filterByNewsItemId; - return View(); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task Comments(string filterByNewsItemId, DataSourceRequest command) - { - var comments = await _newsViewModelService.PrepareNewsCommentModel(filterByNewsItemId, command.Page, command.PageSize); - - var gridModel = new DataSourceResult { - Data = comments.newsCommentModels.ToList(), - Total = comments.totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task CommentDelete(NewsComment model) - { - if (ModelState.IsValid) - { - await _newsViewModelService.CommentDelete(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseNewsController (ARCH-001 News consolidation). All shared +// behavior lives in the base; this class only supplies Admin's DI wiring plus the attributes that +// used to arrive transitively via BaseAdminController - BaseNewsController can't inherit any single +// host's base controller. Same pattern as CategoryController (see that file). +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class NewsController( + INewsViewModelService newsViewModelService, + INewsService newsService, + ILanguageService languageService, + ITranslationService translationService, + IStoreService storeService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseNewsController(newsViewModelService, newsService, languageService, translationService, + storeService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs new file mode 100644 index 0000000000..edf63ebff9 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseNewsController.cs @@ -0,0 +1,226 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Cms; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.News; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.News; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.News)] +[AutoValidateAntiforgeryToken] +public abstract class BaseNewsController( + INewsViewModelService newsViewModelService, + INewsService newsService, + ILanguageService languageService, + ITranslationService translationService, + IStoreService storeService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings on Edit(GET) that aren't access-scope + /// decisions. Overridden by the Store subclass (Task 4); no-op everywhere else. Fourth occurrence + /// of this exact pattern in ARCH-001 (Category, Blog, Page, now News) - treat as proven. + protected virtual void EditWarningCheck(NewsItem newsItem) { } + + // Exposed for host subclasses: primary-constructor parameters are not visible to derived classes + // by name in C#. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + + #region News items + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = new NewsItemListModel(); + if (scope.DefaultStoreId is null) + { + 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 }); + } + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task List(DataSourceRequest command, NewsItemListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var news = await newsViewModelService.PrepareNewsItemModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = news.newsItemModels.ToList(), + Total = news.totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + ViewBag.AllLanguages = await languageService.GetAllLanguages(true); + var model = new NewsItemModel { + Published = true, + AllowComments = true + }; + await AddLocales(languageService, model.Locales); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(NewsItemModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + // Store's original called _newsService.UpdateNews(newsItem) again here, right after + // InsertNewsItemModel (which already inserts internally) - a redundant double-write with + // no purpose. Dropped as a ruled, disclosed fix. + var newsItem = await newsViewModelService.InsertNewsItemModel(model); + Success(translationService.GetResource("Admin.Content.News.NewsItems.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = newsItem.Id }) : RedirectToAction("List"); + } + + // Ruled fix: Admin's original assigned the unawaited Task here instead of the resolved list. + ViewBag.AllLanguages = await languageService.GetAllLanguages(true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var newsItem = await newsService.GetNewsById(id); + if (newsItem == null) return RedirectToAction("List"); + + EditWarningCheck(newsItem); + if (!await scope.CanView(newsItem)) return RedirectToAction("List"); + + ViewBag.AllLanguages = await languageService.GetAllLanguages(true); + var model = newsItem.ToModel(dateTimeService); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Title = newsItem.GetTranslation(x => x.Title, languageId, false); + locale.Short = newsItem.GetTranslation(x => x.Short, languageId, false); + locale.Full = newsItem.GetTranslation(x => x.Full, languageId, false); + locale.MetaKeywords = newsItem.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = newsItem.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = newsItem.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = newsItem.GetSeName(languageId, false); + }); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(NewsItemModel model, bool continueEditing) + { + var newsItem = await newsService.GetNewsById(model.Id); + if (newsItem == null) return RedirectToAction("List"); + if (!await scope.HasAccess(newsItem)) return RedirectToAction("Edit", new { id = newsItem.Id }); + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) model.Stores = [scope.DefaultStoreId]; + newsItem = await newsViewModelService.UpdateNewsItemModel(newsItem, model); + Success(translationService.GetResource("Admin.Content.News.NewsItems.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = newsItem.Id }); + } + return RedirectToAction("List"); + } + + ViewBag.AllLanguages = await languageService.GetAllLanguages(true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var newsItem = await newsService.GetNewsById(id); + if (newsItem == null) return RedirectToAction("List"); + // News-specific: denial redirects to List, NOT Edit (matches Page's precedent, not + // Blog/Category's own Delete target) - verified directly from Store's original source. + if (!await scope.HasAccess(newsItem)) return RedirectToAction("List"); + + await newsService.DeleteNews(newsItem); + Success(translationService.GetResource("Admin.Content.News.NewsItems.Deleted")); + return RedirectToAction("List"); + } + + #endregion + + #region Comments + + // Ruled fix (PR review): Admin's original returned View() here with no Comments.cshtml ever + // existing for News (unlike Blog, which has one) - a preexisting 500 if this GET were ever hit + // directly. Unreachable from the UI (the Comments tab's grid calls the POST overload only), so + // redirect instead of carrying the dead View() call forward. + public IActionResult Comments(string filterByNewsItemId) => RedirectToAction("List"); + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task Comments(string filterByNewsItemId, DataSourceRequest command) + { + var newsItem = await newsService.GetNewsById(filterByNewsItemId); + // Comments/CommentDelete are newly shared with Store in this phase; Admin's original had no + // check here (GlobalAdminDataScope makes CanView a no-op for Admin), but Store now needs one. + // CanView, not HasAccess: this is a read reachable from the same Edit screen that already lets + // a Store user view (not just mutate) global/multi-store news items via scope.CanView in + // Edit(GET) - HasAccess would wrongly deny the Comments tab for those items. An empty/missing + // filterByNewsItemId makes GetNewsById return null, denying here before + // PrepareNewsCommentModel's own no-filter branch could return every store's comments. + if (newsItem == null || !await scope.CanView(newsItem)) + return ErrorForKendoGridJson("No access to this news item's comments"); + + var comments = await newsViewModelService.PrepareNewsCommentModel(filterByNewsItemId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = comments.newsCommentModels.ToList(), + Total = comments.totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task CommentDelete(NewsComment model) + { + var newsItem = await newsService.GetNewsById(model.NewsItemId); + // Admin's original had no scope check on CommentDelete at all - but Comments/CommentDelete are + // newly shared with Store in this phase (fixing a live bug where Store's Edit.cshtml already + // rendered a Comments tab whose grid called actions that didn't exist), so this check is + // required for Store's benefit even though Admin's original never needed one (GlobalAdminDataScope + // makes it a no-op for Admin). Same pattern Blog's own CommentDelete already uses. + if (newsItem == null || !await scope.HasAccess(newsItem)) + return ErrorForKendoGridJson("No access to this news item's comments"); + + if (ModelState.IsValid) + { + await newsViewModelService.CommentDelete(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedNewsItemDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedNewsItemDataScope.cs new file mode 100644 index 0000000000..35971e3c97 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedNewsItemDataScope.cs @@ -0,0 +1,55 @@ +#nullable enable + +using Grand.Domain.News; +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<NewsItem>, X>() per host would silently let whichever +/// host's StartupApplication ran last win for every area in that process. +/// +/// No Vendor branch: Vendor has no News screen at all, so any "Vendor" (or other +/// unrecognized/missing) area value fails closed. +/// +public class RoutedNewsItemDataScope( + 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" - NewsItem has no Vendor screen) must never + //silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedNewsItemDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(NewsItem entity) => Resolved.HasAccess(entity); + + public Task CanView(NewsItem 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 3025bff674..f5e6867989 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -5,6 +5,7 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Domain.Discounts; +using Grand.Domain.News; using Grand.Domain.Messages; using Grand.Domain.Orders; using Grand.Domain.Pages; @@ -195,6 +196,12 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped>(); services.AddScoped, RoutedSpecificationAttributeDataScope>(); + // IAdminDataScope: registered once here for the same reason as Category above — see + // RoutedNewsItemDataScope's doc comment. No Vendor scope: News has no Vendor screen. + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped, RoutedNewsItemDataScope>(); + // IAdminDataScope: registered once here for the same reason as Category above — see // RoutedPageDataScope's doc comment. No Vendor scope: Page has no Vendor screen. services.AddScoped>(); diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Create.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Create.cshtml index e22daf70e0..1e57793db4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Create.cshtml @@ -1,9 +1,11 @@ -@model NewsItemModel +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel @{ //page title ViewBag.Title = Loc["Admin.Content.News.NewsItems.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -24,7 +26,7 @@ - +
@@ -34,4 +36,4 @@ -
\ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Edit.cshtml similarity index 84% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Edit.cshtml index 93b26d3ec3..d532fb4871 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Edit.cshtml @@ -1,9 +1,11 @@ -@model NewsItemModel +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel @{ //page title ViewBag.Title = Loc["Admin.Content.News.NewsItems.EditNewsItemDetails"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -31,7 +33,7 @@ @Loc["Admin.Common.Delete"] - +
@@ -42,4 +44,4 @@
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/List.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/List.cshtml similarity index 74% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/News/List.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/News/List.cshtml index a3592d6bd6..85f7d2db41 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/News/List.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/List.cshtml @@ -1,8 +1,10 @@ -@model NewsItemListModel +@using Grand.Web.AdminShared.Models.News +@model NewsItemListModel @inject AdminAreaSettings adminAreaSettings @{ //page title ViewBag.Title = Loc["Admin.Content.News.NewsItems"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); }
@@ -14,33 +16,36 @@ @Loc["Admin.Content.News.NewsItems"]
-
-
-
-
-
-
- - + @if (area == "Admin") + { +
+
+
+
+
+
+ + +
-
-
-
- +
+
+ +
-
+ }
@@ -59,7 +64,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("List", "News", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("List", "News", new { area }))", type: "POST", dataType: "json", data: additionalData @@ -142,10 +147,11 @@ }); function additionalData() { - var data = { - SearchStoreId: $('#SearchStoreId').val() - }; + var data = {}; + if ($('#SearchStoreId').length) { + data.SearchStoreId = $('#SearchStoreId').val(); + } addAntiForgeryToken(data); return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabComments.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Partials/CreateOrUpdate.TabComments.cshtml similarity index 86% rename from src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabComments.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Partials/CreateOrUpdate.TabComments.cshtml index 266dc45ae6..cad52bca2b 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabComments.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/News/Partials/CreateOrUpdate.TabComments.cshtml @@ -1,14 +1,18 @@ -@model NewsItemModel +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel @inject AdminAreaSettings adminAreaSettings - +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} + @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- +
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabInfo.cshtml deleted file mode 100644 index c0a489daa7..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.TabInfo.cshtml +++ /dev/null @@ -1,101 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model NewsItemModel - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
- -
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 9e3c8074e4..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,30 +0,0 @@ -@model NewsItemModel - -
- - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml new file mode 100644 index 0000000000..82f8209392 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Create.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml new file mode 100644 index 0000000000..0e19ff0714 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsButtons.Edit.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml new file mode 100644 index 0000000000..9c1b0796e9 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml new file mode 100644 index 0000000000..3c1134921c --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsList.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml new file mode 100644 index 0000000000..bf9e08c601 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsCommentsTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml new file mode 100644 index 0000000000..62f90d6dfe --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml new file mode 100644 index 0000000000..e3527cc9e1 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsInfoTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml new file mode 100644 index 0000000000..042ab22008 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoBottom.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml new file mode 100644 index 0000000000..35f1b1f32c --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsSeoTop.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 0000000000..ecbc510c16 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,3 @@ +@using Grand.Web.AdminShared.Models.News +@model NewsItemModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.ListButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.ListButtons.cshtml new file mode 100644 index 0000000000..3cbcb5a33c --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/News/Partials/WidgetZone.ListButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Controllers/NewsController.cs b/src/Web/Grand.Web.Store/Controllers/NewsController.cs index e931e92880..a5012ac112 100644 --- a/src/Web/Grand.Web.Store/Controllers/NewsController.cs +++ b/src/Web/Grand.Web.Store/Controllers/NewsController.cs @@ -1,235 +1,61 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Cms; -using Grand.Business.Core.Interfaces.Common.Configuration; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Domain.News; using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.News; -using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.News)] -public class NewsController : BaseStoreController +// Reduced to a thin subclass of BaseNewsController (ARCH-001 News consolidation). All shared +// behavior lives in the base; this class supplies Store's DI wiring, the EditWarningCheck hook, and +// the kept Preview action (Admin has no equivalent). Same pattern as CategoryController's +// EditWarningCheck override (see that file). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class NewsController( + INewsViewModelService newsViewModelService, + INewsService newsService, + ILanguageService languageService, + ITranslationService translationService, + IStoreService storeService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseNewsController(newsViewModelService, newsService, languageService, translationService, + storeService, dateTimeService, scope) { - #region Constructors - - public NewsController( - INewsViewModelService newsViewModelService, - INewsService newsService, - ILanguageService languageService, - ITranslationService translationService, - ISettingService settingService, - IDateTimeService dateTimeService, - IContextAccessor contextAccessor) - { - _newsViewModelService = newsViewModelService; - _newsService = newsService; - _languageService = languageService; - _translationService = translationService; - _settingService = settingService; - _dateTimeService = dateTimeService; - _contextAccessor = contextAccessor; - } - - #endregion - - #region Fields - - private readonly INewsViewModelService _newsViewModelService; - private readonly INewsService _newsService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly ISettingService _settingService; - private readonly IDateTimeService _dateTimeService; - private readonly IContextAccessor _contextAccessor; - - #endregion - - #region News items - - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - var model = new NewsItemListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task List(DataSourceRequest command, NewsItemListModel model) - { - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var newsSettings = await _settingService.LoadSetting(storeId); - - var news = await _newsService.GetAllNews(storeId, command.Page - 1, command.PageSize, newsTitle: model.SearchNewsTitle); - - var gridModel = new DataSourceResult { - Data = news.Select(x => - { - var m = x.ToModel(_dateTimeService); - m.Full = ""; - m.CreatedOn = _dateTimeService.ConvertToUserTime(x.CreatedOnUtc, DateTimeKind.Utc); - m.Comments = x.CommentCount; - return m; - }).ToList(), - Total = news.TotalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new NewsItemModel { - //default values - Published = true, - AllowComments = true - }; - - //locales - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(NewsItemModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - var newsItem = await _newsViewModelService.InsertNewsItemModel(model); - await _newsService.UpdateNews(newsItem); - - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = newsItem.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var newsItem = await _newsService.GetNewsById(id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - - if (!newsItem.LimitedToStores || (newsItem.LimitedToStores && - newsItem.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && - newsItem.Stores.Count > 1)) - { - Warning(_translationService.GetResource("Admin.Content.News.Permissions")); - } - else - { - if (!newsItem.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - } - - var model = newsItem.ToModel(_dateTimeService); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Title = newsItem.GetTranslation(x => x.Title, languageId, false); - locale.Short = newsItem.GetTranslation(x => x.Short, languageId, false); - locale.Full = newsItem.GetTranslation(x => x.Full, languageId, false); - locale.MetaKeywords = newsItem.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = newsItem.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = newsItem.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = newsItem.GetSeName(languageId, false); - }); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(NewsItemModel model, bool continueEditing) - { - var newsItem = await _newsService.GetNewsById(model.Id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - - if (!newsItem.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("Edit", new { id = newsItem.Id }); - - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - newsItem = await _newsViewModelService.UpdateNewsItemModel(newsItem, model); - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = newsItem.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) + // Re-derived from the original Store NewsController.Edit(GET) - 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. Fourth occurrence of this exact idiom in ARCH-001 + // (Category, Blog, Page, now News) - treat as proven. + protected override void EditWarningCheck(NewsItem newsItem) { - var newsItem = await _newsService.GetNewsById(id); - if (newsItem == null) - //No news item found with the specified id - return RedirectToAction("List"); - - if (!newsItem.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _newsService.DeleteNews(newsItem); - - Success(_translationService.GetResource("Admin.Content.News.NewsItems.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id = newsItem.Id }); + if (!newsItem.LimitedToStores || + (newsItem.Stores.Contains(Scope.DefaultStoreId) && + newsItem.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Content.News.Permissions")); } + // Admin has no equivalent action - a genuine Store-only addition, kept on the concrete subclass + // rather than the shared base. [PermissionAuthorizeAction(PermissionActionName.Preview)] public async Task Preview(string id) { - var newsItem = await _newsService.GetNewsById(id); - if (newsItem == null) - return RedirectToAction("List"); - - if (!newsItem.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); + var newsItem = await newsService.GetNewsById(id); + if (newsItem == null) return RedirectToAction("List"); + if (!await Scope.HasAccess(newsItem)) return RedirectToAction("List"); - var model = newsItem.ToModel(_dateTimeService); + var model = newsItem.ToModel(dateTimeService); return View(model); } - - #endregion -} \ No newline at end of file +}