From 848adeec5c389b7063dbebef221388897b1c1391 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 14:20:12 +0200 Subject: [PATCH 001/147] Add design spec for ARCH-001 Product panel consolidation Two-phase design: Phase 1 consolidates ProductController and ProductViewModelService into Grand.Web.AdminShared via a new IAdminDataScope abstraction, following the existing BaseLoginController precedent. Phase 2 consolidates the Product views via an extended ViewLocationExpander. Scoped to the Product vertical only; other entities and a full panel merge are out of scope. Co-Authored-By: Claude Sonnet 5 --- ...16-arch001-product-consolidation-design.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md diff --git a/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md b/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md new file mode 100644 index 0000000000..33373262d9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md @@ -0,0 +1,150 @@ +# ARCH-001 — Product panel consolidation design + +Date: 2026-08-16 +Status: Approved, ready for implementation planning + +## Problem + +`Grand.Web.Admin`, `Grand.Web.Store`, and `Grand.Web.Vendor` each carry their own +copy of `ProductController` (2478 / 2625 / 2584 lines) and, for the view-model +layer, `Grand.Web.AdminShared` and `Grand.Web.Vendor` each carry their own +`ProductViewModelService` (2571 / 2381 lines, 1768 lines of diff). The copies +have drifted too far to merge mechanically. A bug fix or security patch in the +product editor currently requires three independent edits, and history shows +that requirement gets missed (commits #754, #765 fixed antiforgery handling in +some panels but not all). Full finding recorded in project memory +`project_arch001_triple_admin_duplication.md`. + +This spec covers **only the `Product` vertical** (`ProductController` + +`ProductViewModelService` + `Product` views) as the first, highest-value slice +of ARCH-001. It does not attempt to generalize to every entity yet, though the +core abstraction is named and shaped so Order/Category/Collection can adopt it +later without redesign. + +## Existing precedent + +`Grand.Web.AdminShared/Controllers/BaseLoginController.cs` already implements +this exact pattern: an abstract base controller in AdminShared, with three +21-line subclasses (`Grand.Web.Admin/Store/Vendor/Controllers/LoginController.cs`) +that add only `[Area(...)]` and pass constructor args through. This design +follows that precedent at Product's scale. + +Recent groundwork already in place (as of 2026-08-16): +- Characterization tests exist for all three `ProductController`s + (`src/Tests/Grand.Web.{Admin,Store,Vendor}.Tests/Controllers/ProductControllerTests.cs`) + and for both `ProductViewModelService`s (`Grand.Web.Admin.Tests` covers + AdminShared's, `Grand.Web.Vendor.Tests` covers Vendor's). +- #786 deduped Store's `ProductController` access checks onto a single + `CanAccessProduct` helper. +- #785 deduped Vendor's access checks similarly. +- #788 synced Vendor's `ProductViewModelService` to AdminShared's + primary-constructor style, reducing incidental diff noise before a merge. + +These give a safety net for a direct migration (no parallel-run / feature flag +needed — chosen deliberately over a flagged rollout given the test coverage +already in place). + +## Current access-scope patterns (what `IAdminDataScope` must replace) + +- **Admin**: no filtering — global access to all products. +- **Store** (`Grand.Web.Store/Controllers/ProductController.cs`): scattered + direct reads of `_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId`, + used both to filter lists/queries and as a default value written onto new/ + edited products (`model.StoreId`, `model.Stores`, list search filters). +- **Vendor** (`Grand.Web.Vendor/Controllers/ProductController.cs`): entity-level + checks via `_contextAccessor.WorkContext.HasAccessToProduct(product)`, + applied per-action rather than as a list filter. + +## Architecture + +A new abstraction in `Grand.Web.AdminShared`: + +```csharp +public interface IAdminDataScope +{ + Task HasAccess(TEntity entity); + IQueryable ApplyScope(IQueryable query); + string? DefaultStoreId { get; } // null for Admin/Vendor, StaffStoreId for Store +} +``` + +Three implementations, one per host, each registered in that host's own +`Startup` (matching how each host registers its own services today): + +- `GlobalAdminDataScope` (Admin) — `HasAccess` always true, `ApplyScope` + is a no-op, `DefaultStoreId` is `null`. +- `StoreAdminDataScope` (Store) — wraps `StaffStoreId` filtering/ + defaulting in one place instead of the current scattered call sites. +- `VendorAdminDataScope` (Vendor) — delegates to the existing + `IWorkContext.HasAccessToProduct` (or the generalized equivalent). + +`DefaultStoreId` exists specifically to make today's implicit per-host +default (Store always stamps `model.StoreId`; Admin never does) explicit and +testable instead of an artifact of the diff. + +The interface is typed generically (``) and named without a `Product` +suffix so Order/Category/Collection can implement it later, but this spec +only ships the `Product` instantiation and only what `ProductController` +actually needs — no speculative members beyond the three above. + +## Phase 1 — Controller and service consolidation + +- `Grand.Web.AdminShared/Controllers/BaseProductController.cs`: the union of + today's three controllers' action logic, with every `StaffStoreId`/ + `HasAccessToProduct` call site replaced by calls into the injected + `IAdminDataScope`. +- Three per-host `ProductController : BaseProductController` subclasses, + reduced to `[Area(...)]` + constructor pass-through, matching + `LoginController`. +- `Grand.Web.AdminShared/Services/ProductViewModelService.cs` gains whatever + Vendor's copy has that AdminShared's doesn't. Each real difference found in + the 1768-line diff must be attributed to a scope decision (`IAdminDataScope`) + or ported as shared behavior — never copy-pasted as a parallel branch. + Vendor's own `Services/ProductViewModelService.cs` is deleted; Vendor starts + consuming AdminShared's, as Store already does. +- Existing characterization tests are the migration's correctness gate: they + move to (or are consolidated into) `Grand.Web.AdminShared.Tests`, plus thin + per-host tests that check only routing/authorization attributes. New unit + tests cover the three `IAdminDataScope` implementations directly. +- Phase 1 ships as an independently mergeable, fully working change — no + half-migrated state, no flag. + +## Phase 2 — View consolidation + +- `Product/*.cshtml` views (51 Admin / 51 Store / 49 Vendor) move to + `Grand.Web.AdminShared/Views/Product/`. +- `Grand.Web.Common/View/ViewLocationExpander.cs` (today handles only the + storefront `ThemeKey` case) gains an admin-area branch: when the executing + controller derives from `BaseProductController`, AdminShared's view folder + is added as a fallback location. +- Views whose only difference is the hardcoded area string + (`Constants.AreaAdmin`/`AreaStore`/`AreaVendor`) are unified into one + AdminShared view using the request's current area instead. +- Views with a real functional difference (e.g. Admin-only bulk export panel + on `List.cshtml`) stay as host-specific overrides, resolved before the + AdminShared fallback by the expander. +- Phase 2 depends on Phase 1 (needs `BaseProductController` to exist as the + branch condition) but is its own mergeable unit with its own review + checkpoint — work can pause between phases without leaving a broken or + half-migrated state. + +## Testing + +- Phase 1: run and green all migrated/consolidated `ProductControllerTests` + and `ProductViewModelServiceTests` (Admin/Store/Vendor), plus new + `IAdminDataScope` unit tests. +- Phase 2: manual/characterization pass over rendered Product screens per + host (List, Create, Edit, and the tabs/partials with known host-specific + content) to confirm the expander resolves views correctly and overrides + render where expected. + +## Out of scope + +- Any entity other than Product (Order, Category, Collection, etc.) — future + work, enabled but not started by this spec. +- Merging the three hosts into a single deployable app — explicitly rejected + in the ARCH-001 finding; auth models, data scopes, and independent + deployability stay separate. +- A generalized `IAdminAreaContext` covering area name + capability flags — + considered (design option C) and deferred as speculative beyond what + Product needs today. From 44d15360eba216fe57460466997d45c438eff7f2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:02:01 +0200 Subject: [PATCH 002/147] Add ARCH-001 Phase 1 implementation plan (Product controller + service consolidation) Co-Authored-By: Claude Sonnet 5 --- ...16-arch001-product-consolidation-phase1.md | 1376 +++++++++++++++++ 1 file changed, 1376 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md new file mode 100644 index 0000000000..ba58d555bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -0,0 +1,1376 @@ +# ARCH-001 Product Consolidation — Phase 1 (Controller + Service) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Tasks 8 and 10 are per-item checklists (one region / one method each) — when using subagent-driven-development, dispatch one subagent per checklist row, not one subagent for the whole task. + +**Goal:** Eliminate the three duplicated `ProductController` classes (Admin/Store/Vendor) and the two duplicated `ProductViewModelService` classes (AdminShared/Vendor) by consolidating the logic into `Grand.Web.AdminShared`, scoped per host through a new `IAdminDataScope` strategy, with each host reduced to a thin subclass — mirroring the existing `BaseLoginController` pattern. + +**Architecture:** A new `IAdminDataScope` (data filtering/access/default-value strategy) and a `ResourceKeyPrefix` (host-specific localization key prefix) are injected into a single `BaseProductController` and a single `ProductViewModelService`, both living in `Grand.Web.AdminShared`. Each host (`Grand.Web.Admin`, `Grand.Web.Store`, `Grand.Web.Vendor`) registers its own `IAdminDataScope` implementation and keeps only a ~20-line `ProductController : BaseProductController` subclass declaring `[Area(...)]`. + +**Tech Stack:** ASP.NET Core MVC, C# 13 primary constructors, MSTest + Moq (existing test stack per `.ai/knowledge/tests.md`), MongoDB repositories (unaffected — no data-layer changes in this plan). + +**Spec:** `docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md` + +## Global Constraints + +- Do not merge the three hosts into one deployable app — separate auth models, data scopes, and independent deployability stay separate (spec, "Out of scope"). +- Direct migration, no feature flag / parallel-run — chosen deliberately because characterization tests already exist for all three `ProductController`s and both `ProductViewModelService`s (see Task 0). +- `IAdminDataScope` ships only what `Product` needs (`HasAccess`, `ApplyScope`, `DefaultStoreId`) — no speculative members for entities not yet migrated. +- Every host-specific literal (StaffStoreId access, `HasAccessToProduct`, `"Admin."`/`"Vendor."` resource-key prefixes) must route through `IAdminDataScope` or `ResourceKeyPrefix` — no residual host-specific `if` branches left inside `BaseProductController` or the shared service. +- This plan is Phase 1 only (controller + service). Views (Phase 2) are a separate, later plan per the spec. +- Follow existing repo conventions: primary-constructor DI (see `95c8548bf`), `.ai/standards/csharp-style.md`, `.ai/knowledge/mongodb.md` for any repository-layer touches (none expected in this plan). + +--- + +## Task 0: Baseline — confirm the safety net before touching anything + +**Files:** +- Read only: `src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs` +- Read only: `src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs` +- Read only: `src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs` +- Read only: `src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs` +- Read only: `src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs` + +**Interfaces:** none (read-only verification task). + +- [ ] **Step 1: Run all five existing test files and confirm they currently pass** + +Run: +``` +dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~ProductController|FullyQualifiedName~ProductViewModelService" +dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~ProductController" +dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~ProductController|FullyQualifiedName~ProductViewModelService" +``` +Expected: all PASS. If anything fails here, stop and fix or report it before starting Task 1 — this plan's safety net depends on a green baseline. + +- [ ] **Step 2: Note the test project namespaces/base classes used** + +Skim each file's `using` block and test class setup (constructor mocks) — later tasks reuse these mock-setup patterns when writing new `IAdminDataScope` tests. No code change in this step. + +--- + +## Task 1: `IAdminDataScope` interface + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs` + +**Interfaces:** +- Produces: `IAdminDataScope` with `Task HasAccess(TEntity entity)`, `IQueryable ApplyScope(IQueryable query)`, `string? DefaultStoreId { get; }`, `string ResourceKeyPrefix { get; }` — consumed by Tasks 2-4 (implementations) and Task 7+ (`BaseProductController`)/Task 9+ (shared service). + +- [ ] **Step 1: Write the interface** + +```csharp +namespace Grand.Web.AdminShared.Interfaces; + +/// +/// Per-host data-access strategy for an admin-area entity. Implemented once per host +/// (Admin/Store/Vendor) and injected into shared AdminShared controllers/services so +/// scope logic lives in one place instead of being duplicated per host. +/// +public interface IAdminDataScope +{ + /// Whether the current user may access this specific, already-loaded entity. + Task HasAccess(TEntity entity); + + /// Narrows a query to the entities the current user may see. No-op for global (Admin) scope. + IQueryable ApplyScope(IQueryable query); + + /// Store id to default onto new/edited entities. Null when the host has no store concept + /// (Admin: global, no default; Vendor: not store-scoped at all). + string? DefaultStoreId { get; } + + /// Prefix used to build host-specific localization keys, e.g. "Admin", "Vendor". Store + /// currently has no distinct resource set and uses "Admin" (see Task 6). + string ResourceKeyPrefix { get; } +} +``` + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj` +Expected: Build succeeded (interface has no consumers yet, so nothing else changes). + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs +git commit -m "Add IAdminDataScope abstraction (ARCH-001 Phase 1)" +``` + +--- + +## Task 2: `GlobalAdminDataScope` (Admin host) + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs` +- Test: `src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs` + +**Interfaces:** +- Consumes: `IAdminDataScope` (Task 1). +- Produces: `GlobalAdminDataScope : IAdminDataScope` — registered by Admin's `Startup` in Task 5. + +- [ ] **Step 1: Write the failing test** + +```csharp +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class GlobalAdminDataScopeTests +{ + [TestMethod] + public async Task HasAccess_AlwaysReturnsTrue() + { + var scope = new GlobalAdminDataScope(); + var result = await scope.HasAccess(new Product()); + Assert.IsTrue(result); + } + + [TestMethod] + public void ApplyScope_ReturnsQueryUnchanged() + { + var scope = new GlobalAdminDataScope(); + var query = new[] { new Product { Id = "1" }, new Product { Id = "2" } }.AsQueryable(); + + var result = scope.ApplyScope(query); + + CollectionAssert.AreEqual(query.ToList(), result.ToList()); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new GlobalAdminDataScope(); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + var scope = new GlobalAdminDataScope(); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~GlobalAdminDataScopeTests"` +Expected: FAIL (compile error — `GlobalAdminDataScope` does not exist yet). + +- [ ] **Step 3: Write the implementation** + +```csharp +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class GlobalAdminDataScope : IAdminDataScope +{ + public Task HasAccess(TEntity entity) => Task.FromResult(true); + + public IQueryable ApplyScope(IQueryable query) => query; + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Admin"; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~GlobalAdminDataScopeTests"` +Expected: PASS (4/4). + +- [ ] **Step 5: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs +git commit -m "Add GlobalAdminDataScope for the Admin host (ARCH-001 Phase 1)" +``` + +--- + +## Task 3: `StoreAdminDataScope` (Store host) + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs` +- Test: `src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs` + +**Interfaces:** +- Consumes: `IAdminDataScope` (Task 1), existing `IContextAccessor`/`IWorkContext` (`Grand.Infrastructure`), existing `IStoreLinkEntity` (`Grand.Domain.Stores` — already implemented by `Product`, confirmed at `src/Core/Grand.Domain/Catalog/Product.cs:754`). +- Produces: `StoreAdminDataScope where TEntity : IStoreLinkEntity` — registered by Store's `Startup` in Task 5. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Services; + +[TestClass] +public class StoreAdminDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string StaffStoreId = "store-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + [TestMethod] + public async Task HasAccess_ProductNotLimitedToStores_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = false }; + + Assert.IsTrue(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToOtherStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToStaffStore_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId] }; + + Assert.IsTrue(await scope.HasAccess(product)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual(StaffStoreId, scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + // Store has no distinct resource set for Product screens yet (see Task 6) — it renders + // AdminShared's "Admin.*" keys today, so the migrated scope must keep that behavior. + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~StoreAdminDataScopeTests"` +Expected: FAIL (compile error — type doesn't exist). + +- [ ] **Step 3: Write the implementation** + +```csharp +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class StoreAdminDataScope(IContextAccessor contextAccessor) : IAdminDataScope + where TEntity : IStoreLinkEntity +{ + public Task HasAccess(TEntity entity) + { + if (entity is null) return Task.FromResult(false); + + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); + return Task.FromResult(allowed); + } + + public IQueryable ApplyScope(IQueryable query) + { + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + if (string.IsNullOrEmpty(staffStoreId)) return query; + return query.Where(x => !x.LimitedToStores || x.Stores.Contains(staffStoreId)); + } + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public string ResourceKeyPrefix => "Admin"; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~StoreAdminDataScopeTests"` +Expected: PASS (5/5). + +- [ ] **Step 5: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs +git commit -m "Add StoreAdminDataScope for the Store host (ARCH-001 Phase 1)" +``` + +**Note for Task 7/8:** the current `Grand.Web.Store/Controllers/ProductController.cs:88-92` `CanAccessProduct` helper (added in #786, uses `product.AccessToEntityByStore(staffStoreId)`) and this `HasAccess` implementation must agree. `AccessToEntityByStore` is the existing extension in `Grand.Business.Core.Extensions`; check its exact semantics against the `HasAccess` body above during Task 7 Step 1 and use whichever is authoritative (prefer calling the existing `AccessToEntityByStore` extension from inside `HasAccess` over reimplementing the same rule twice, if its signature fits `IStoreLinkEntity`). + +--- + +## Task 4: `VendorProductDataScope` (Vendor host, Product-specific) + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs` +- Test: `src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs` + +**Interfaces:** +- Consumes: `IAdminDataScope` (Task 1), existing `IContextAccessor`/`IWorkContext.CurrentVendor`. +- Produces: `VendorProductDataScope : IAdminDataScope` — registered by Vendor's `Startup` in Task 5. + +Named `VendorProductDataScope` (not generic `VendorAdminDataScope`): vendor ownership is keyed by `VendorId` on `Product`, but other entities (`Order`, `Shipment`, ...) use different owner fields (see `src/Web/Grand.Web.Vendor/Extensions/HasAccess.cs`), so a generic vendor scope would need a marker interface that doesn't exist yet. Out of scope for this plan — future entities get their own `VendorDataScope` when migrated, following this same shape. + +- [ ] **Step 1: Write the failing tests** + +```csharp +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Vendor.Tests.Services; + +[TestClass] +public class VendorProductDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string VendorId = "vendor-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentVendor).Returns(new Domain.Vendors.Vendor { Id = VendorId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + [TestMethod] + public async Task HasAccess_OwnProduct_ReturnsTrue() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.HasAccess(new Product { VendorId = VendorId })); + } + + [TestMethod] + public async Task HasAccess_OtherVendorsProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(new Product { VendorId = "vendor-2" })); + } + + [TestMethod] + public async Task HasAccess_NullProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(null!)); + } + + [TestMethod] + public void ApplyScope_FiltersToOwnVendorId() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + var query = new[] + { + new Product { Id = "1", VendorId = VendorId }, + new Product { Id = "2", VendorId = "vendor-2" } + }.AsQueryable(); + + var result = scope.ApplyScope(query).ToList(); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("1", result[0].Id); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsVendor() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~VendorProductDataScopeTests"` +Expected: FAIL (compile error). + +- [ ] **Step 3: Write the implementation** + +```csharp +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class VendorProductDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Product entity) + { + if (entity is null) return Task.FromResult(false); + return Task.FromResult(entity.VendorId == contextAccessor.WorkContext.CurrentVendor.Id); + } + + public IQueryable ApplyScope(IQueryable query) + { + var vendorId = contextAccessor.WorkContext.CurrentVendor.Id; + return query.Where(x => x.VendorId == vendorId); + } + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Vendor"; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~VendorProductDataScopeTests"` +Expected: PASS (6/6). + +- [ ] **Step 5: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs +git commit -m "Add VendorProductDataScope for the Vendor host (ARCH-001 Phase 1)" +``` + +**Note:** `Grand.Web.Vendor/Extensions/HasAccess.cs:19` (`HasAccessToProduct`) stays as-is — it's still used by other Vendor controllers (Order, Shipment, etc. also call sibling `HasAccessTo*` methods from the same file) that are out of scope for this plan. Do not delete or modify that file in this plan. + +--- + +## Task 5: DI registration in all three hosts + +**Files:** +- Modify: `src/Web/Grand.Web.Admin/Startup/*.cs` (find via Step 1 below) +- Modify: `src/Web/Grand.Web.Store/Startup/*.cs` +- Modify: `src/Web/Grand.Web.Vendor/Startup/*.cs` + +**Interfaces:** +- Consumes: `GlobalAdminDataScope`, `StoreAdminDataScope`, `VendorProductDataScope` (Tasks 2-4). +- Produces: `IAdminDataScope` resolvable via DI in each host — consumed by `BaseProductController` (Task 7) and the shared `ProductViewModelService` (Task 9). + +- [ ] **Step 1: Find where each host registers its own services today** + +Run: `grep -rln "AddScoped, GlobalAdminDataScope>(); +``` +(Add `using Grand.Web.AdminShared.Interfaces;`, `using Grand.Web.AdminShared.Services;`, `using Grand.Domain.Catalog;` if not already present.) + +- [ ] **Step 3: Register in Store** + +In the file found for `Grand.Web.Store`, add: +```csharp +services.AddScoped, StoreAdminDataScope>(); +``` + +- [ ] **Step 4: Register in Vendor** + +In the file found for `Grand.Web.Vendor`, add: +```csharp +services.AddScoped, VendorProductDataScope>(); +``` + +- [ ] **Step 5: Build all three hosts** + +Run: +``` +dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj +``` +Expected: all succeed (registration has no consumers yet, so this only proves the DI call compiles). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Register IAdminDataScope in Admin, Store, Vendor hosts (ARCH-001 Phase 1)" +``` + +--- + +## Task 6: Resource-key-prefix audit for the Product vertical + +**Files:** +- No source changes — this task produces a decision table consumed by Tasks 7-10. + +**Interfaces:** none. + +**Why:** confirmed by direct comparison of `PrepareProductListModel` in AdminShared vs Vendor — Vendor's copy uses `"Vendor.Common.All"` / `"Vendor.Catalog.Products.List.SearchPublished.*"` where AdminShared's uses `"Admin.*"`. `ResourceKeyPrefix` (Task 1) exists to make this swap mechanical, but **do not assume every `Admin.X` key has a matching `Vendor.X` key** — some resources may only exist under one prefix, in which case the key must stay a literal, not be templated. + +- [ ] **Step 1: Extract every resource key literal referenced by the three controllers and both services** + +Run (from repo root): +``` +grep -ohE 'GetResource\("[A-Za-z]+\.[^"]+"\)' \ + src/Web/Grand.Web.Admin/Controllers/ProductController.cs \ + src/Web/Grand.Web.Store/Controllers/ProductController.cs \ + src/Web/Grand.Web.Vendor/Controllers/ProductController.cs \ + src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs \ + src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs \ + | sed -E 's/GetResource\("([A-Za-z]+)\.([^"]+)"\)/\1|\2/' \ + | sort -u > /tmp/product-resource-keys.txt +``` + +- [ ] **Step 2: Group by suffix (the part after the first `.`) and diff prefixes** + +For each unique suffix, note which prefixes (`Admin`, `Store`, `Vendor`) appear. Three outcomes: +1. Only `Admin` appears (e.g. today's Store host, which has no separate resource set) → keep the literal `"Admin."` in the shared code; do not template it. +2. Both `Admin` and `Vendor` appear for the same suffix (confirmed case: `Common.All`, `Catalog.Products.List.SearchPublished.*`, `Catalog.Products.Added`, `Catalog.Products.Updated`, `Catalog.Products.Deleted`, `Catalog.Products.Fields.ChangedWarning`, `Catalog.Products.Permissions`) → template as `$"{scope.ResourceKeyPrefix}."`. +3. A prefix+suffix combination appears in only one host with no equivalent elsewhere and looks like a real per-host resource (not just a stray) → keep it as a literal guarded by `scope.ResourceKeyPrefix == "Vendor"` (or an `if`/virtual override) rather than templating — templating here would silently look up a resource key that doesn't exist for other hosts, which renders as the raw key text. + +- [ ] **Step 3: Save the table as a code comment** + +Add the resulting suffix → outcome table as a comment block at the top of `BaseProductController` (created in Task 7) so it stays next to the code it governs, e.g.: +```csharp +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6): +// Templated via {scope.ResourceKeyPrefix}: Common.All, Catalog.Products.List.SearchPublished.*, +// Catalog.Products.{Added,Updated,Deleted}, Catalog.Products.Fields.ChangedWarning, +// Catalog.Products.Permissions. +// Admin-only literal (Store has no separate resource set): . +// Host-specific, not templated: . +``` + +No commit for this task alone — its output lands inside Task 7's commit. + +--- + +## Task 7: `BaseProductController` skeleton + worked region ("Product list / create / edit / delete") + +This is the template every remaining region in Task 8 follows. Do this one region fully and correctly before touching any other region. + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` +- Test: `src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs` (new file — see Task 13 for what happens to the three existing per-host `ProductControllerTests.cs` files) + +**Interfaces:** +- Consumes: `IAdminDataScope` (Task 1, registered per-host in Task 5), existing `IProductViewModelService`, `IProductService`, and the other 8 services already injected by all three current controllers (see the three constructors — they are identical in shape). +- Produces: `BaseProductController` — subclassed by all three hosts in Task 11. + +- [ ] **Step 1: Reconcile the three current `List`/`Create`/`Edit`/`Delete`/`CopyProduct` bodies** + +Read (already done for this plan — reproduced here for reference, do not re-read unless verifying): +- `src/Web/Grand.Web.Admin/Controllers/ProductController.cs:78-291` +- `src/Web/Grand.Web.Store/Controllers/ProductController.cs:94-...` (equivalent region, includes the `CanAccessProduct` helper at line 88) +- `src/Web/Grand.Web.Vendor/Controllers/ProductController.cs:91-307` (includes the `CheckAccessToProduct` helper at line 81) + +Differences found, and how each is resolved: +| Difference | Admin | Store | Vendor | Resolution | +|---|---|---|---|---| +| Access check on `Edit`/`Delete`/`CopyProduct` | none | `CanAccessProduct` (`AccessToEntityByStore`) | `CheckAccessToProduct` (`HasAccessToProduct`) | `await scope.HasAccess(product)` | +| `GoToSku` access check | none | checks `CanAccessProduct` (note: current Store code has a **pre-existing bug** — it redirects to `Edit` regardless of whether `CanAccessProduct` returns true or false; preserve as a separate follow-up, do not silently fix inside this refactor — see Step 1a) | none | `if (product != null) { if (!await scope.HasAccess(product)) { /* preserve existing per-host behavior, see 1a */ } return RedirectToAction("Edit", new { id = product.Id }); }` | +| `List()` storeId arg | `PrepareProductListModel()` | `PrepareProductListModel(StaffStoreId)` | `PrepareProductListModel()` | `PrepareProductListModel(scope.DefaultStoreId ?? "")` once Task 9 lands; until then keep the current 4 arities separate per interface signature | +| `Create()` GET default `model.StoreId` | not set | `= StaffStoreId` | not set | `model.StoreId = scope.DefaultStoreId;` (no-op when null) | +| `Create()`/`Edit()` POST `model.Stores`/`model.StoreId` stamping | not set | `[StaffStoreId]` / `StaffStoreId` | not set | `if (scope.DefaultStoreId is not null) { model.Stores = [scope.DefaultStoreId]; model.StoreId = scope.DefaultStoreId; }` | +| `Edit()` GET extra "still has other stores" warning branch (Store only, lines 184-194) | absent | present | absent | Keep as a `protected virtual` no-op hook `EditWarningCheck(Product product)` overridden only in the Store subclass (Task 11) — this is host UI copy behavior, not scope logic, so it does not belong in `IAdminDataScope`. | +| `PrepareProductModel(model, product, bool, bool)` arity | 4-arg | 4-arg | **3-arg** (no `excludeProperties`) | Resolved by Task 9 (interface unification) — until Task 9 lands, `BaseProductController` calls the 4-arg AdminShared signature with `excludeProperties: false` as Vendor's implicit default; verify against Vendor's actual usage (`false` in `Create()`, `true` in the redisplay-on-invalid branches) before assuming — re-check `src/Web/Grand.Web.Vendor/Controllers/ProductController.cs:139,157,174,222` line by line. | +| Resource key prefix | `"Admin.*"` | `"Admin.*"` | `"Vendor.*"` | `$"{scope.ResourceKeyPrefix}.Catalog.Products.Added"` etc., per Task 6's table | +| `DeleteSelected` | present | absent (verify — grep confirms only Admin/Vendor define it; if Store truly has no `DeleteSelected` action, keep it but note the missing UI wiring is pre-existing and out of scope) | present | Keep the action in the base class; it's harmless if a host's view never posts to it | + +- [ ] **Step 1a: File a note, don't fix, the Store `GoToSku` bug found above** + +Add a `// TODO(ARCH-001-followup):` comment at the merged `GoToSku` call site describing the found inconsistency (Store's current code redirects to Edit on both branches of the access check) and leave the **new, merged** behavior doing the historically-safer thing: redirect to `List` (not `Edit`) when `HasAccess` is false, matching Vendor's stricter pattern, since silently landing on the edit screen of a product you don't have access to is the more dangerous default. Call this out explicitly in the PR description this task's commit goes into — this is a deliberate behavior tightening, not an accidental one. + +- [ ] **Step 2: Write `BaseProductController` with this region only** + +```csharp +using Grand.Business.Core.Dto; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.StaticFiles; + +namespace Grand.Web.AdminShared.Controllers; + +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6): + +[PermissionAuthorize(PermissionSystemName.Products)] +public abstract class BaseProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass; no-op everywhere else. + protected virtual void EditWarningCheck(Product product) { } + + #region Product list / create / edit / delete + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = await productViewModelService.PrepareProductListModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, ProductListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (productModels, totalCount) = + await productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); + return Json(new DataSourceResult { Data = productModels.ToList(), Total = totalCount }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task GoToSku(ProductListModel model) + { + var product = await productService.GetProductBySku(model.GoDirectlyToSku); + if (product != null) + { + if (!await scope.HasAccess(product)) + return RedirectToAction("List", "Product"); // TODO(ARCH-001-followup): see Task 7 Step 1a + return RedirectToAction("Edit", "Product", new { id = product.Id }); + } + + Warning(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SkuNotFound")); + return RedirectToAction("List", "Product"); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new ProductModel { StoreId = scope.DefaultStoreId }; + await productViewModelService.PrepareProductModel(model, null, true, true); + await AddLocales(languageService, model.Locales); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(ProductModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + var product = await productViewModelService.InsertProductModel(model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, null, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + + EditWarningCheck(product); + if (!await scope.HasAccess(product)) return RedirectToAction("List"); + + var model = product.ToModel(dateTimeService); + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, false); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = product.GetTranslation(x => x.Name, languageId, false); + locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); + locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); + locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = product.GetSeName(languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(ProductModel model, bool continueEditing) + { + var product = await productService.GetProductById(model.Id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id = product.Id }); + + if (model.Ticks != product.Ticks) + { + Error(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Fields.ChangedWarning")); + return RedirectToAction("Edit", new { id = product.Id }); + } + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + product = await productViewModelService.UpdateProductModel(product, model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = product.Id }); + } + + return RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id }); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProduct(product); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task DeleteSelected(ICollection selectedIds) + { + if (selectedIds != null) await productViewModelService.DeleteSelected(selectedIds.ToList()); + return Json(new { Result = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + public async Task CopyProduct(ProductModel model, + [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) + { + var copyModel = model.CopyProductModel; + try + { + var originalProduct = await productService.GetProductById(copyModel.Id, true); + if (!await scope.HasAccess(originalProduct)) return RedirectToAction("List"); + + if (scope.DefaultStoreId is not null) + { + originalProduct.LimitedToStores = true; + originalProduct.Stores.Clear(); + originalProduct.Stores.Add(scope.DefaultStoreId); + } + + var newProduct = await copyProductService.CopyProduct(originalProduct, copyModel.Name, copyModel.Published); + if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); + + Success("The product has been copied successfully"); + return RedirectToAction("Edit", new { id = newProduct.Id }); + } + catch (Exception exc) + { + Error(exc.Message); + return RedirectToAction("Edit", new { id = copyModel.Id }); + } + } + + private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) + { + foreach (var productPicture in originalProduct.ProductPictures) + { + var picture = await pictureService.GetPictureById(productPicture.PictureId); + var pictureCopy = await pictureService.InsertPicture( + await pictureService.LoadPictureBinary(picture), + picture.MimeType, + pictureService.GetPictureSeName(newProduct.Name), + picture.AltAttribute, + picture.TitleAttribute, + false, + Reference.Product, + newProduct.Id); + + await productService.InsertProductPicture(new ProductPicture { + PictureId = pictureCopy.Id, + DisplayOrder = productPicture.DisplayOrder, + IsDefault = productPicture.IsDefault + }, newProduct.Id); + } + } + + #endregion +} +``` + +Note: `originalProduct.VendorId` is untouched by `CopyProduct` above — verify against Vendor's actual current behavior (does a vendor-copied product get `VendorId` stamped anywhere, e.g. inside `copyProductService.CopyProduct`?) during Step 1; if Vendor's controller relies on `CopyProduct` internally reading `IWorkContext.CurrentVendor`, no controller-level change is needed and this note can be deleted. + +- [ ] **Step 3: Write `BaseProductControllerTests` covering the merged access-check behavior** + +Port the `Edit`/`Delete`/`CopyProduct`/`GoToSku` access-denied and access-granted test cases already present across the three existing `ProductControllerTests.cs` files (Task 0) into this one file, parameterized over a mocked `IAdminDataScope` instead of three different concrete access mechanisms. Use `Moq` to set up `scope.HasAccess(...)` returning `true`/`false` per case — this replaces, rather than duplicates, the equivalent cases in the per-host files (removed in Task 13). + +- [ ] **Step 4: Run the new tests** + +Run: `dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~BaseProductControllerTests"` +Expected: PASS. `BaseProductController` is abstract and has no host yet, so these tests instantiate it via a minimal test-only subclass (e.g. `private class TestProductController(...) : BaseProductController(...)`) if MSTest/Moq cannot mock an abstract class directly for the actions under test. + +- [ ] **Step 5: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +git commit -m "Add BaseProductController with the list/create/edit/delete region (ARCH-001 Phase 1)" +``` + +Do **not** proceed to Task 11 (host subclasses) yet — `BaseProductController` is incomplete until Task 8 migrates the remaining 23 regions. + +--- + +## Task 8: Migrate the remaining 23 `#region`s into `BaseProductController` + +One region per checklist row, each following Task 7's template exactly: read the region in all three current controllers, build the difference table, resolve every difference through `scope`/`ResourceKeyPrefix`/a `protected virtual` hook, append the merged region to `BaseProductController`, port the corresponding test cases, run tests, commit. Each row is independently testable and commit-able — do not batch multiple rows into one commit. + +**Files (per row):** +- Modify: `src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` (append the region) +- Modify: `src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs` (append test cases for the region) +- Read: the matching region in all three of `src/Web/Grand.Web.{Admin,Store,Vendor}/Controllers/ProductController.cs`, located by the line numbers below (current as of this plan's authoring — re-locate by region name if line numbers have drifted). + +| # | Region | Admin start line | Store start line* | Vendor start line* | +|---|---|---|---|---| +| 1 | Required products | 293 | (grep `#region Required products`) | (grep) | +| 2 | Product categories | 347 | " | " | +| 3 | Product collections | 415 | " | " | +| 4 | Related products | 483 | " | " | +| 5 | Similar products | 576 | " | " | +| 6 | Bundle products | 669 | " | " | +| 7 | Cross-sell products | 762 | " | " | +| 8 | Recommended products | 845 | " | " | +| 9 | Associated products | 927 | " | " | +| 10 | Product pictures | 1029 | " | " | +| 11 | Product specification attributes | 1161 | " | " | +| 12 | Purchased with order | 1278 | " | " | +| 13 | Reviews | 1308 | " | " | +| 14 | Export / Import | 1338 | " | " | +| 15 | Bulk editing | 1406 | " | " | +| 16 | Product currency price | 1446 | " | " | +| 17 | Tier prices | 1559 | " | " | +| 18 | Product attributes | 1673 | " | " | +| 19 | Product attributes. Condition | 1791 | " | " | +| 20 | Product attribute values | 1828 | " | " | +| 21 | Product attribute combinations | 2031 | " | " | +| 22 | Product Attribute combination - tier prices | 2139 | " | " | +| 23 | Reservation | 2219 | " | " | +| 24 | Bids | 2433 | " | " | + +*Store and Vendor line numbers were not pre-extracted for every region (only the shared region list, confirmed identical region names/order across all three files via `grep -n "#region" ` on 2026-08-16). Locate each region in Store/Vendor with `grep -n "#region " src/Web/Grand.Web.{Store,Vendor}/Controllers/ProductController.cs` at the start of that row's work — do not assume the same line number as Admin. + +- [ ] **Step 1 (repeat per row): Read the region in all three controllers and build the difference table** + +Same procedure as Task 7 Step 1. Pay special attention to: +- Any `_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId` or `.CurrentVendor` reference → route through `scope`. +- Any `HasAccessToProduct`/`CanAccessProduct`/`CheckAccessToProduct` call → `await scope.HasAccess(...)`. +- Any `"Admin.*"` / `"Vendor.*"` resource key → check against Task 6's table before templating. +- Any region present in only one or two of the three controllers (not all 24 regions are guaranteed to exist verbatim in all three — confirm with `grep -c "#region" ` per host before assuming symmetry; a region missing from one host means that host's subclass simply never routes to those actions, which is fine — the shared method still exists, just unused by that host's views). + +- [ ] **Step 2 (repeat per row): Append the merged region and its tests, run tests, commit** + +```bash +dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~BaseProductControllerTests" +git add src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +git commit -m "Migrate '' region into BaseProductController (ARCH-001 Phase 1)" +``` + +- [ ] **Step 3: After all 24 rows are committed, confirm `BaseProductController`'s member list is a superset of all three original controllers' public actions** + +Run: +``` +grep -oE "public (async )?(Task|IActionResult|JsonResult)[^(]*\(" src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs | sort -u > /tmp/base.txt +grep -oE "public (async )?(Task|IActionResult|JsonResult)[^(]*\(" src/Web/Grand.Web.Admin/Controllers/ProductController.cs | sort -u > /tmp/admin.txt +grep -oE "public (async )?(Task|IActionResult|JsonResult)[^(]*\(" src/Web/Grand.Web.Store/Controllers/ProductController.cs | sort -u > /tmp/store.txt +grep -oE "public (async )?(Task|IActionResult|JsonResult)[^(]*\(" src/Web/Grand.Web.Vendor/Controllers/ProductController.cs | sort -u > /tmp/vendor.txt +diff /tmp/base.txt /tmp/admin.txt +diff /tmp/base.txt /tmp/store.txt +diff /tmp/base.txt /tmp/vendor.txt +``` +Expected: no unexplained diffs (action name + parameter type list should match; return-type wrapper differences like `Task` vs `IActionResult` are fine to differ if intentional). Any method present in an old controller but missing from `BaseProductController` is a gap — go back and migrate it before moving to Task 9. + +--- + +## Task 9: Unify `IProductViewModelService` — drop `storeId` params, inject `IAdminDataScope`, worked method (`PrepareProductListModel`) + +**Files:** +- Modify: `src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` +- Modify: `src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs` +- Test: `src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs` (existing file, extend) + +**Interfaces:** +- Consumes: `IAdminDataScope` (Task 1). +- Produces: `IProductViewModelService.PrepareProductListModel()` (no `storeId` parameter — scope comes from the injected `IAdminDataScope` instead) — consumers updated in Task 8's regions and `BaseProductController.List()` (Task 7, revisit). + +**Design decision (confirmed by comparing the two current interfaces at `src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` vs `src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs`):** AdminShared's interface threads scope through explicit `storeId` parameters on ~10 methods; Vendor's interface has no such parameters and instead reads `IWorkContext.CurrentVendor` internally. Converging on **caller-supplied `storeId` parameters is wrong for Vendor** (vendor scope isn't a store id at all) — converge on **DI-injected `IAdminDataScope` inside the service**, matching what `BaseProductController` already does, and remove the `storeId` parameters entirely. This changes the public interface — every call site found in Task 8 must be updated in the same commit as the interface change to keep the build green (do this task before, or interleaved with, whichever Task 8 rows call an affected method — recommend doing Task 9 immediately after Task 8's `#region Product list / create / edit / delete`-adjacent rows are done, since `PrepareProductListModel`/`PrepareProductsModel` are used there). + +- [ ] **Step 1: List every interface method with a `storeId` parameter** + +From the interface diff (already gathered for this plan): +``` +PrepareProductModel(ProductModel, Product, bool, bool) // excludeProperties: keep 4-arg, see Task 7 Step 1 +PrepareTierPriceModel(ProductModel.TierPriceModel, string storeId = "") +PrepareProductListModel(string storeId = "") +PrepareAddRequiredProductModel(string storeId = "") +PrepareRelatedProductModel(string storeId = "") +PrepareSimilarProductModel(string storeId = "") +PrepareBundleProductModel(string storeId = "") +PrepareCrossSellProductModel(string storeId = "") +PrepareRecommendedProductModel(string storeId = "") +PrepareAssociatedProductModel(string storeId = "") +PrepareBulkEditListModel(string storeId = "") +PrepareTierPriceModel(Product, string storeId = "") +PrepareAssociateProductToAttributeValueModel(string storeId = "") +``` +Also reconcile the three non-storeId signature mismatches found in the diff: +``` +OutOfStockNotifications(Product, ProductModel, int) // AdminShared vs OutOfStockNotifications(Product, int) // Vendor — extra ProductModel param, verify whether Vendor's body needs it or genuinely doesn't +UpdateProductSpecificationAttributeModel(Product, ProductSpecificationAttribute, ...) // AdminShared vs (ProductSpecificationAttribute, ...) // Vendor — extra Product param +``` +These two need their bodies read (not just signatures) during this task — read `src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs` at the matching method names to see whether the omitted parameter is actually unused or silently sourced from elsewhere (e.g. re-fetched inside the method). + +- [ ] **Step 2: Remove `storeId` from every method above in `IProductViewModelService.cs`** + +Example (repeat pattern for all 13 methods): +```csharp +// before +Task PrepareProductListModel(string storeId = ""); +// after +Task PrepareProductListModel(); +``` + +- [ ] **Step 3: Add `IAdminDataScope scope` to the service's primary constructor and rewrite `PrepareProductListModel`** + +```csharp +public virtual async Task PrepareProductListModel() +{ + var model = new ProductListModel(); + var storeId = scope.DefaultStoreId ?? ""; + + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + + model.AvailableWarehouses.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); + foreach (var wh in await warehouseService.GetAllWarehouses(storeId)) + model.AvailableWarehouses.Add(new SelectListItem { Text = wh.Name, Value = wh.Id }); + + model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); + model.AvailableProductTypes.Insert(0, new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "0" }); + + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.All"), Value = " " }); + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.PublishedOnly"), Value = "1" }); + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.UnpublishedOnly"), Value = "2" }); + + // Admin/Store show "Show on homepage" (value 3); Vendor's current copy omits it entirely (vendors can't + // feature products on the homepage — a real capability difference, not a naming difference). Gate it: + if (scope.ResourceKeyPrefix != "Vendor") + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.ShowOnHomePage"), Value = "3" }); + + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.MarkAsNew"), Value = "4" }); + + return model; +} +``` + +Note the `if (scope.ResourceKeyPrefix != "Vendor")` gate: this is a real capability difference (found by reading both bodies for this plan — Vendor's method has no "Show on homepage" option at all, Admin/Store do), not just a resource-prefix difference. Using `ResourceKeyPrefix` as the gate condition here is a shortcut that works today (only Vendor differs) but is semantically about capability, not localization — if a fourth host is ever added, replace this with a proper `bool CanFeatureOnHomepage` on `IAdminDataScope` rather than continuing to overload `ResourceKeyPrefix` for behavior gating. Leave a comment saying so. + +Also drop the `model.AvailableStores` population entirely when `scope is VendorProductDataScope`-equivalent (Vendor's original method has no store dropdown at all — vendors don't pick stores). Since `DefaultStoreId` is `null` for Vendor already, `storeId` will be `""` for Vendor same as Admin — that would incorrectly populate the stores dropdown for Vendor. Add an explicit capability flag instead of overloading `DefaultStoreId is null` (which is also true for Admin, where the dropdown *should* show): introduce a fourth member on `IAdminDataScope` — `bool ShowStoreSelector { get; }` (`true` for Admin, `true` for Store, `false` for Vendor) — go back to Task 1-4 and add it now: +```csharp +// IAdminDataScope addition: +bool ShowStoreSelector { get; } +// GlobalAdminDataScope: => true; StoreAdminDataScope: => true; VendorProductDataScope: => false; +``` +Update Task 1-4's tests to cover this new member (one assertion each) before continuing. + +- [ ] **Step 4: Update the two call sites already migrated in Task 7 (`BaseProductController.List()`)** + +```csharp +// before (Task 7) +var model = await productViewModelService.PrepareProductListModel(scope.DefaultStoreId ?? ""); +// after +var model = await productViewModelService.PrepareProductListModel(); +``` + +- [ ] **Step 5: Run the full Admin service test suite** + +Run: `dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~ProductViewModelServiceTests"` +Expected: existing `PrepareProductListModel` tests need updating for the new no-arg signature — update them to construct the service with a mocked `IAdminDataScope` (three variants: default/global, store-scoped, vendor-scoped) instead of passing a `storeId` string, and add a case asserting the homepage option and store dropdown are absent when the mock reports `ResourceKeyPrefix == "Vendor"` / `ShowStoreSelector == false`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs src/Tests/*/Services/*DataScopeTests.cs +git commit -m "Unify IProductViewModelService onto IAdminDataScope, drop storeId params; add ShowStoreSelector (ARCH-001 Phase 1)" +``` + +This will not build yet — `src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs` still implements the *old* Vendor-only `IProductViewModelService` (different interface, different namespace) and hasn't been touched. It stays broken/unreferenced until Task 12 deletes it. Confirm the build error is scoped to that one file (`dotnet build Grand.Web.AdminShared.csproj` should succeed on its own) before committing. + +--- + +## Task 10: Reconcile the remaining ~29 `ProductViewModelService` methods + +Same per-row discipline as Task 8: one method (or tightly-coupled small group, e.g. the three attribute-value overloads) per row, each read in both AdminShared and Vendor, differences resolved via `scope`, tested, committed independently. + +**Files (per row):** +- Modify: `src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs` +- Modify: `src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` (drop any remaining `storeId` param for that method) +- Test: `src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs` +- Read: `src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs` at the matching method (method names are shared — same name, different arity/body — locate with `grep -n "MethodName" src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs`). + +**Checklist (from the AdminShared method list; each unchecked row still has a `storeId` param or an unverified arity mismatch with Vendor per Task 9 Step 1):** +- [ ] `PrepareAddProductAttributeCombinationModel` +- [ ] `PrepareTierPriceModel(ProductModel.TierPriceModel, storeId)` — drop `storeId` +- [ ] `PrepareProductAttributeValueModel(Product, ...)` +- [ ] `PrepareProductModel(ProductModel, Product, bool, bool)` — resolve the 4-arg/3-arg `excludeProperties` mismatch flagged in Task 7 Step 1 +- [ ] `PrepareProductReviewModel` +- [ ] `PrepareProductsModel` +- [ ] `PrepareProducts(ProductListModel)` +- [ ] `PrepareAddRequiredProductModel(storeId)` — drop `storeId` +- [ ] `PrepareProductModel(DataSourceRequest-ish overload at line 816)` +- [ ] `PrepareProductCategoryModel` +- [ ] `PrepareProductCollectionModel` +- [ ] `PrepareRelatedProductModel(storeId)` — drop `storeId` +- [ ] `PrepareSimilarProductModel(storeId)` — drop `storeId` +- [ ] `PrepareBundleProductModel(storeId)` — drop `storeId` +- [ ] `PrepareCrossSellProductModel(storeId)` — drop `storeId` +- [ ] `PrepareRecommendedProductModel(storeId)` — drop `storeId` +- [ ] `PrepareAssociatedProductModel(storeId)` — drop `storeId` +- [ ] `PrepareBulkEditListModel(storeId)` — drop `storeId` +- [ ] `PrepareTierPriceModel(Product, storeId)` — drop `storeId` +- [ ] `PrepareBidMode` +- [ ] `PrepareProductAttributeMappingModel` (4 overloads at lines 1365/1379/1392/1526 — AdminShared has one more overload than Vendor per the interface diff; confirm which one and whether Vendor needs it) +- [ ] `PrepareProductAttributeMappingModels` +- [ ] `PrepareProductAttributeConditionModel` +- [ ] `PrepareProductAttributeValueModel` (2 more overloads at 1715/1798) +- [ ] `PrepareProductAttributeValueModels` +- [ ] `PrepareAssociateProductToAttributeValueModel(storeId)` — drop `storeId` +- [ ] `PrepareProductAttributeCombinationModel` +- [ ] `PrepareProductPicturesModel` +- [ ] `PrepareProductPictureModel` +- [ ] `PrepareProductSpecificationAttributeModel` +- [ ] `OutOfStockNotifications` — resolve the `ProductModel` param mismatch flagged in Task 9 Step 1 +- [ ] `UpdateProductSpecificationAttributeModel` — resolve the `Product` param mismatch flagged in Task 9 Step 1 +- [ ] `InsertProductModel`, `UpdateProductModel`, `DeleteProduct`, `DeleteSelected` — confirm identical between AdminShared/Vendor already (likely candidates for "no change needed", but verify, don't assume) + +- [ ] **Step 1 (repeat per row): read both bodies, resolve differences, update interface + implementation + tests, run tests, commit** + +```bash +dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~ProductViewModelServiceTests" +git add src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +git commit -m "Reconcile in ProductViewModelService (ARCH-001 Phase 1)" +``` + +- [ ] **Step 2: After all rows are checked off, confirm no `storeId` parameters remain** + +Run: `grep -n "storeId" src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` +Expected: no matches (or only local variables inside method bodies that read `scope.DefaultStoreId`, not parameters). + +--- + +## Task 11: Convert the three host `ProductController`s to thin subclasses + +**Files:** +- Modify (rewrite, shrink): `src/Web/Grand.Web.Admin/Controllers/ProductController.cs` +- Modify (rewrite, shrink): `src/Web/Grand.Web.Store/Controllers/ProductController.cs` +- Modify (rewrite, shrink): `src/Web/Grand.Web.Vendor/Controllers/ProductController.cs` + +**Interfaces:** +- Consumes: `BaseProductController` (Tasks 7-8, now complete with all 24 regions). + +- [ ] **Step 1: Replace Admin's controller** + +```csharp +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Localization; +using Grand.Domain.Catalog; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[Area(Constants.AreaAdmin)] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope); +``` + +(Fix the exact `using`s/namespaces for `IInventoryManageService`, `IProductReservationService`, `IAuctionService`, `IPermissionService`, `IEnumTranslationService` by copying them from the current file's `using` block before deleting it — don't re-guess namespaces not already confirmed in this plan.) + +- [ ] **Step 2: Replace Store's controller the same way, plus the `EditWarningCheck` override** + +```csharp +namespace Grand.Web.Store.Controllers; + +[Area(Constants.AreaStore)] +public class ProductController(/* same params as Admin */) : BaseProductController(/* same args */) +{ + protected override void EditWarningCheck(Product product) + { + if (!product.LimitedToStores || (product.LimitedToStores && product.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Catalog.Products.Permissions")); + } +} +``` +Re-derive the exact condition from the original Store code at `src/Web/Grand.Web.Store/Controllers/ProductController.cs:184-194` (reproduced in Task 7's Step 1 table) rather than retyping from memory — the original condition is unusual (it warns when NOT limited, or when limited AND accessible AND multi-store) and easy to get backwards. `TranslationService` needs to be exposed as a `protected` member on `BaseProductController` (it's currently a primary-constructor parameter, which C# does not expose to derived classes by name — add `protected ITranslationService TranslationService => translationService;` to `BaseProductController` in this step, or store a `protected readonly` field instead of a primary-constructor parameter for any member a subclass needs to reference). + +- [ ] **Step 3: Replace Vendor's controller the same way** + +No `EditWarningCheck` override needed (Vendor's original code has no equivalent branch). + +- [ ] **Step 4: Build all three hosts** + +Run: +``` +dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj +``` +Expected: Vendor still fails — it's still registering `Grand.Web.Vendor.Interfaces.IProductViewModelService` (old interface) rather than AdminShared's, and its own `ProductViewModelService` class still exists. That's resolved in Task 12; if Admin and Store also fail, stop and fix before proceeding (they should build clean at this point). + +- [ ] **Step 5: Commit (Admin + Store only; hold Vendor's controller change until Task 12 makes it buildable)** + +```bash +git add src/Web/Grand.Web.Admin/Controllers/ProductController.cs src/Web/Grand.Web.Store/Controllers/ProductController.cs src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +git commit -m "Reduce Admin and Store ProductController to thin BaseProductController subclasses (ARCH-001 Phase 1)" +``` + +Vendor's rewritten controller from Step 3 stays as an uncommitted working-tree change until Task 12. + +--- + +## Task 12: Delete Vendor's duplicate service and interface, finish Vendor's DI wiring + +**Files:** +- Delete: `src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs` +- Delete: `src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs` +- Modify: Vendor's `Startup` DI registration file (found in Task 5 Step 1) — repoint `IProductViewModelService` registration to `Grand.Web.AdminShared.Services.ProductViewModelService` / `Grand.Web.AdminShared.Interfaces.IProductViewModelService` +- Modify: any other Vendor file referencing `Grand.Web.Vendor.Interfaces.IProductViewModelService` or `Grand.Web.Vendor.Models.Catalog.*` types that moved — find with Step 1 + +**Interfaces:** +- Consumes: `Grand.Web.AdminShared.Interfaces.IProductViewModelService` / `.Services.ProductViewModelService` (Tasks 9-10, now fully reconciled). + +- [ ] **Step 1: Find every remaining reference to the old Vendor-local types** + +Run: +``` +grep -rln "Grand.Web.Vendor.Interfaces.IProductViewModelService\|Grand.Web.Vendor.Services.ProductViewModelService" src/Web/Grand.Web.Vendor --include=*.cs +grep -rln "using Grand.Web.Vendor.Interfaces;" src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +``` + +- [ ] **Step 2: Delete the two files** + +```bash +git rm src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs +git rm src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs +``` + +- [ ] **Step 3: Update DI registration and every file found in Step 1 to reference AdminShared's interface/namespace instead** + +Swap `using Grand.Web.Vendor.Interfaces;` → `using Grand.Web.AdminShared.Interfaces;` and `AddScoped()` → `AddScoped()` (AdminShared's), matching how Admin/Store already register it (check their `Startup` files for the exact existing line to mirror). + +- [ ] **Step 4: Build Vendor** + +Run: `dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj` +Expected: succeeds now. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "Delete Vendor's duplicate ProductViewModelService/interface, use AdminShared's (ARCH-001 Phase 1)" +``` + +--- + +## Task 13: Consolidate characterization tests, delete superseded per-host duplicates + +**Files:** +- Modify/trim: `src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs` +- Modify/trim: `src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs` +- Modify/trim: `src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs` +- Delete: `src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs` (superseded by `Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs`, which now covers all scope variants per Tasks 9-10) + +**Interfaces:** none new — this task only removes now-redundant test coverage and confirms the replacement (`BaseProductControllerTests`, extended `ProductViewModelServiceTests`) covers the same cases. + +- [ ] **Step 1: For each of the three per-host `ProductControllerTests.cs`, identify which test cases exercised logic that now lives in `BaseProductController`** + +Any test asserting scope/access-check behavior (e.g. "Edit returns RedirectToList when product belongs to another store") is now covered by `BaseProductControllerTests` (Task 7 Step 3, extended through Task 8). Any test asserting host-specific routing/area/authorization-attribute behavior only (e.g. "controller has `[Area(\"Admin\")]`") stays in the per-host file, since `BaseProductController` doesn't carry that attribute. + +- [ ] **Step 2: Trim each per-host test file down to routing/attribute-only cases** + +Remove the now-duplicated scope/business-logic cases; keep a small smoke test confirming the subclass resolves via DI and inherits the base actions, e.g.: +```csharp +[TestMethod] +public void ProductController_IsBaseProductController() +{ + Assert.IsInstanceOfType(CreateController()); +} +``` + +- [ ] **Step 3: Delete the superseded Vendor service test file** + +```bash +git rm src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs +``` + +- [ ] **Step 4: Run every Product-related test across all three test projects** + +Run: +``` +dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~Product" +dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~Product" +dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~Product" +``` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "Trim per-host Product tests to routing-only, consolidate scope tests into BaseProductControllerTests (ARCH-001 Phase 1)" +``` + +--- + +## Task 14: Full-solution verification + +**Files:** none — verification only. + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build GrandNode.sln` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 2: Full test run for the three web test projects** + +Run: +``` +dotnet test src/Tests/Grand.Web.Admin.Tests +dotnet test src/Tests/Grand.Web.Store.Tests +dotnet test src/Tests/Grand.Web.Vendor.Tests +``` +Expected: all PASS. Per `project_test_suite_flaky_parallel` (project memory), run each project individually rather than via a single solution-wide `dotnet test` — the full-solution parallel run is known to flake on unrelated Customers/Marketing/Messages suites. + +- [ ] **Step 3: Line-count sanity check against the ARCH-001 baseline** + +Run: `wc -l src/Web/Grand.Web.Admin/Controllers/ProductController.cs src/Web/Grand.Web.Store/Controllers/ProductController.cs src/Web/Grand.Web.Vendor/Controllers/ProductController.cs src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` +Expected: the three host controllers are each roughly 20-40 lines (matching `LoginController`'s shape); `BaseProductController.cs` accounts for the bulk of what was previously ~2500 lines ×3. + +- [ ] **Step 4: Manual smoke test (if a local Kestrel instance is available per `reference_running_the_storefront`)** + +Log into each of the three admin panels and open Product → List → Edit → Save for one existing product per host, confirming no runtime DI resolution errors and that store/vendor scoping still restricts what's visible (a Store-scoped or Vendor-scoped login should not see or be able to open another store's/vendor's product). + +- [ ] **Step 5: Update the ARCH-001 project memory** + +Edit `project_arch001_triple_admin_duplication.md` (memory file) to note Phase 1 (Product controller + service) is complete, Phase 2 (views) is a separate follow-up plan, and that the pattern is now proven for future entities (Order, Category, Collection). + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "ARCH-001 Phase 1 complete: Product controller and service consolidated into AdminShared" +``` From 9dfbec5b2d05b2c48eb342b841fd2ca7a775ab62 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:04:50 +0200 Subject: [PATCH 003/147] Ignore .worktrees/ directory for local git worktree isolation --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6822fbf4f6..5f318b6be7 100644 --- a/.gitignore +++ b/.gitignore @@ -380,4 +380,4 @@ src/Web/Grand.Web.Store/App_Data/Settings.cfg src/Web/Grand.Web.Store/Plugins/* src/Web/Grand.Web.Store/Modules/* src/Web/Grand.Web.Store/App_Data/DataProtectionKeys/* -src/Web/Grand.Web.Store/wwwroot/assets/images/thumbs/*.** \ No newline at end of file +src/Web/Grand.Web.Store/wwwroot/assets/images/thumbs/*.**.worktrees/ From d529440036b27522f5ca891f11121c144a18652c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:09:39 +0200 Subject: [PATCH 004/147] Plan fix: Tasks 9-10 must also update BaseProductController call sites for dropped storeId params Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-16-arch001-product-consolidation-phase1.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index ba58d555bc..08103de0fc 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -983,7 +983,9 @@ Expected: no unexplained diffs (action name + parameter type list should match; **Files:** - Modify: `src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` - Modify: `src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs` +- Modify: `src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` — every call site of the 13 methods listed in Step 1 that Task 8 already migrated into a region (Required products, Related/Similar/Bundle/Cross-sell/Recommended/Associated products, Bulk editing, Tier prices, the attribute-value-association popup) loses its `storeId`/`scope.DefaultStoreId` argument in this same task. Find them with `grep -n "scope.DefaultStoreId" src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` before starting Step 2 below, and fix every hit that calls one of the 13 methods. - Test: `src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs` (existing file, extend) +- Test: `src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs` — update any test asserting the old arity for the 13 methods **Interfaces:** - Consumes: `IAdminDataScope` (Task 1). @@ -1102,7 +1104,9 @@ Same per-row discipline as Task 8: one method (or tightly-coupled small group, e **Files (per row):** - Modify: `src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs` - Modify: `src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs` (drop any remaining `storeId` param for that method) +- Modify (when the row drops a `storeId` param): `src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs` — grep for the method name and update every call site the same way Task 9 did for `PrepareProductListModel`. - Test: `src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs` +- Test (when a call site changed): `src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs` - Read: `src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs` at the matching method (method names are shared — same name, different arity/body — locate with `grep -n "MethodName" src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs`). **Checklist (from the AdminShared method list; each unchecked row still has a `storeId` param or an unverified arity mismatch with Vendor per Task 9 Step 1):** From 5541be65f52bb604db13e52c8b8acc6fa65b6bd9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:15:53 +0200 Subject: [PATCH 005/147] Plan fix: run Grand.Web.Store.Tests unfiltered (AutoMapperConfig static init lives in PaymentControllerTests) Co-Authored-By: Claude Sonnet 5 --- .../2026-08-16-arch001-product-consolidation-phase1.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index 08103de0fc..d1c99f38ea 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -37,9 +37,11 @@ Run: ``` dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~ProductController|FullyQualifiedName~ProductViewModelService" -dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~ProductController" +dotnet test src/Tests/Grand.Web.Store.Tests dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~ProductController|FullyQualifiedName~ProductViewModelService" ``` +Note: run `Grand.Web.Store.Tests` **unfiltered**, not with a `~ProductController` filter. `AutoMapperConfig` is a static singleton initialized in `PaymentControllerTests.TestInitialize` (`src/Tests/Grand.Web.Store.Tests/Controllers/PaymentControllerTests.cs:48`); filtering it out of the run leaves the mapper uninitialized and fails `ProductControllerTests.EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning` with a `NullReferenceException` that has nothing to do with Product code (confirmed 2026-08-16: 102/102 pass unfiltered, 93/94 pass with the narrow filter). Same applies anywhere else in this plan that filters `Grand.Web.Store.Tests` by `~Product*` — run that project unfiltered instead. + Expected: all PASS. If anything fails here, stop and fix or report it before starting Task 1 — this plan's safety net depends on a green baseline. - [ ] **Step 2: Note the test project namespaces/base classes used** @@ -1326,9 +1328,11 @@ git rm src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs Run: ``` dotnet test src/Tests/Grand.Web.Admin.Tests --filter "FullyQualifiedName~Product" -dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~Product" +dotnet test src/Tests/Grand.Web.Store.Tests dotnet test src/Tests/Grand.Web.Vendor.Tests --filter "FullyQualifiedName~Product" ``` +Run `Grand.Web.Store.Tests` unfiltered — see the note in Task 0 about `AutoMapperConfig`'s static init living in `PaymentControllerTests`; a `~Product` filter on this project produces a false failure unrelated to this plan's changes. + Expected: all PASS. - [ ] **Step 5: Commit** From 7b404b92fa08e09f8e5e18fe9f15ddd64e8df61e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:17:52 +0200 Subject: [PATCH 006/147] Add IAdminDataScope abstraction (ARCH-001 Phase 1) --- .../Interfaces/IAdminDataScope.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs new file mode 100644 index 0000000000..e64f6b3815 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -0,0 +1,25 @@ +#nullable enable + +namespace Grand.Web.AdminShared.Interfaces; + +/// +/// Per-host data-access strategy for an admin-area entity. Implemented once per host +/// (Admin/Store/Vendor) and injected into shared AdminShared controllers/services so +/// scope logic lives in one place instead of being duplicated per host. +/// +public interface IAdminDataScope +{ + /// Whether the current user may access this specific, already-loaded entity. + Task HasAccess(TEntity entity); + + /// Narrows a query to the entities the current user may see. No-op for global (Admin) scope. + IQueryable ApplyScope(IQueryable query); + + /// Store id to default onto new/edited entities. Null when the host has no store concept + /// (Admin: global, no default; Vendor: not store-scoped at all). + string? DefaultStoreId { get; } + + /// Prefix used to build host-specific localization keys, e.g. "Admin", "Vendor". Store + /// currently has no distinct resource set and uses "Admin" (see Task 6). + string ResourceKeyPrefix { get; } +} From 2a5ccc8eb02935da148bb465b61cd6410c58fbc8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:22:06 +0200 Subject: [PATCH 007/147] Add GlobalAdminDataScope for the Admin host (ARCH-001 Phase 1) --- .../Services/GlobalAdminDataScopeTests.cs | 42 +++++++++++++++++++ .../Services/GlobalAdminDataScope.cs | 16 +++++++ 2 files changed, 58 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs new file mode 100644 index 0000000000..18148315e6 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs @@ -0,0 +1,42 @@ +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Services; + +[TestClass] +public class GlobalAdminDataScopeTests +{ + [TestMethod] + public async Task HasAccess_AlwaysReturnsTrue() + { + var scope = new GlobalAdminDataScope(); + var result = await scope.HasAccess(new Product()); + Assert.IsTrue(result); + } + + [TestMethod] + public void ApplyScope_ReturnsQueryUnchanged() + { + var scope = new GlobalAdminDataScope(); + var query = new[] { new Product { Id = "1" }, new Product { Id = "2" } }.AsQueryable(); + + var result = scope.ApplyScope(query); + + CollectionAssert.AreEqual(query.ToList(), result.ToList()); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new GlobalAdminDataScope(); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + var scope = new GlobalAdminDataScope(); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs new file mode 100644 index 0000000000..2f28984ca4 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs @@ -0,0 +1,16 @@ +#nullable enable + +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class GlobalAdminDataScope : IAdminDataScope +{ + public Task HasAccess(TEntity entity) => Task.FromResult(true); + + public IQueryable ApplyScope(IQueryable query) => query; + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Admin"; +} From 76aa1bf203181cca7339cd8662cd7ab35ee1e63e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:26:38 +0200 Subject: [PATCH 008/147] Add StoreAdminDataScope for the Store host (ARCH-001 Phase 1) --- .../Services/StoreAdminDataScopeTests.cs | 67 +++++++++++++++++++ .../Services/StoreAdminDataScope.cs | 31 +++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs diff --git a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs new file mode 100644 index 0000000000..e27abfd5cb --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs @@ -0,0 +1,67 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Services; + +[TestClass] +public class StoreAdminDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string StaffStoreId = "store-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + [TestMethod] + public async Task HasAccess_ProductNotLimitedToStores_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = false }; + + Assert.IsTrue(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToOtherStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + + [TestMethod] + public async Task HasAccess_ProductLimitedToStaffStore_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId] }; + + Assert.IsTrue(await scope.HasAccess(product)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual(StaffStoreId, scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsAdmin() + { + // Store has no distinct resource set for Product screens yet (see Task 6) — it renders + // AdminShared's "Admin.*" keys today, so the migrated scope must keep that behavior. + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.AreEqual("Admin", scope.ResourceKeyPrefix); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs new file mode 100644 index 0000000000..a2087751f4 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -0,0 +1,31 @@ +#nullable enable + +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class StoreAdminDataScope(IContextAccessor contextAccessor) : IAdminDataScope + where TEntity : IStoreLinkEntity +{ + public Task HasAccess(TEntity entity) + { + if (entity is null) return Task.FromResult(false); + + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); + return Task.FromResult(allowed); + } + + public IQueryable ApplyScope(IQueryable query) + { + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + if (string.IsNullOrEmpty(staffStoreId)) return query; + return query.Where(x => !x.LimitedToStores || x.Stores.Contains(staffStoreId)); + } + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public string ResourceKeyPrefix => "Admin"; +} From 00f892e147a1f335371e5f893d3727520be6599d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:28:48 +0200 Subject: [PATCH 009/147] Add StoreAdminDataScope for the Store host (ARCH-001 Phase 1) --- ...16-arch001-product-consolidation-phase1.md | 43 ++++++++++++++----- .../Services/StoreAdminDataScopeTests.cs | 24 +++++++++-- .../Services/StoreAdminDataScope.cs | 11 +++-- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index d1c99f38ea..1057124beb 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -232,13 +232,22 @@ public class StoreAdminDataScopeTests _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); } + // These three mirror AclMappingExtension.AccessToEntityByStore's existing, deliberately strict + // rule (src/Web/Grand.Web.AdminShared/Extensions/AclMappingExtension.cs), the same rule + // ProductController.CanAccessProduct already enforces for Edit(POST)/Delete/CopyProduct today + // (see src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs, + // Delete_ProductNotLimitedToAnyStore_IsDenied and + // Delete_ProductInMultipleStoresIncludingStaffStore_IsDenied, both commented + // "counter-intuitive but current behavior... must not silently fix this"). Access is granted + // ONLY when the product is limited to stores, is in exactly one store, and that store is the + // staff member's store — a global product or one shared across multiple stores is denied. [TestMethod] - public async Task HasAccess_ProductNotLimitedToStores_ReturnsTrue() + public async Task HasAccess_ProductNotLimitedToStores_ReturnsFalse() { var scope = new StoreAdminDataScope(_contextAccessor.Object); var product = new Product { LimitedToStores = false }; - Assert.IsTrue(await scope.HasAccess(product)); + Assert.IsFalse(await scope.HasAccess(product)); } [TestMethod] @@ -251,7 +260,7 @@ public class StoreAdminDataScopeTests } [TestMethod] - public async Task HasAccess_ProductLimitedToStaffStore_ReturnsTrue() + public async Task HasAccess_ProductLimitedToStaffStoreOnly_ReturnsTrue() { var scope = new StoreAdminDataScope(_contextAccessor.Object); var product = new Product { LimitedToStores = true, Stores = [StaffStoreId] }; @@ -259,6 +268,15 @@ public class StoreAdminDataScopeTests Assert.IsTrue(await scope.HasAccess(product)); } + [TestMethod] + public async Task HasAccess_ProductInMultipleStoresIncludingStaffStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + [TestMethod] public void DefaultStoreId_ReturnsStaffStoreId() { @@ -282,32 +300,33 @@ public class StoreAdminDataScopeTests Run: `dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~StoreAdminDataScopeTests"` Expected: FAIL (compile error — type doesn't exist). -- [ ] **Step 3: Write the implementation** +- [ ] **Step 3: Write the implementation — delegate to the existing `AccessToEntityByStore` extension** + +Do not reimplement the store-access rule. `src/Web/Grand.Web.AdminShared/Extensions/AclMappingExtension.cs` already has it (`AccessToEntityByStore(this T entity, string storeId) where T : BaseEntity, IStoreLinkEntity`), and it's the same rule `ProductController.CanAccessProduct` already enforces today. Add the `BaseEntity` constraint and call it directly: ```csharp +using Grand.Domain; using Grand.Domain.Stores; using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; namespace Grand.Web.AdminShared.Services; public class StoreAdminDataScope(IContextAccessor contextAccessor) : IAdminDataScope - where TEntity : IStoreLinkEntity + where TEntity : BaseEntity, IStoreLinkEntity { public Task HasAccess(TEntity entity) { - if (entity is null) return Task.FromResult(false); - var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); - return Task.FromResult(allowed); + return Task.FromResult(entity != null && entity.AccessToEntityByStore(staffStoreId)); } public IQueryable ApplyScope(IQueryable query) { var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; if (string.IsNullOrEmpty(staffStoreId)) return query; - return query.Where(x => !x.LimitedToStores || x.Stores.Contains(staffStoreId)); + return query.Where(x => x.LimitedToStores && x.Stores.Contains(staffStoreId) && x.Stores.Count == 1); } public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; @@ -316,10 +335,12 @@ public class StoreAdminDataScope(IContextAccessor contextAccessor) : IA } ``` +`ApplyScope` mirrors the same strict rule inline (there's no queryable-friendly overload of `AccessToEntityByStore` — it's written for a single loaded entity) so that a product list built through this scope shows exactly the products `HasAccess` would allow, keeping the two methods consistent with each other. + - [ ] **Step 4: Run tests to verify they pass** Run: `dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~StoreAdminDataScopeTests"` -Expected: PASS (5/5). +Expected: PASS (6/6). - [ ] **Step 5: Commit** diff --git a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs index e27abfd5cb..83ccf508f6 100644 --- a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs @@ -22,13 +22,22 @@ public void Setup() _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); } + // These three mirror AclMappingExtension.AccessToEntityByStore's existing, deliberately strict + // rule (src/Web/Grand.Web.AdminShared/Extensions/AclMappingExtension.cs), the same rule + // ProductController.CanAccessProduct already enforces for Edit(POST)/Delete/CopyProduct today + // (see src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs, + // Delete_ProductNotLimitedToAnyStore_IsDenied and + // Delete_ProductInMultipleStoresIncludingStaffStore_IsDenied, both commented + // "counter-intuitive but current behavior... must not silently fix this"). Access is granted + // ONLY when the product is limited to stores, is in exactly one store, and that store is the + // staff member's store — a global product or one shared across multiple stores is denied. [TestMethod] - public async Task HasAccess_ProductNotLimitedToStores_ReturnsTrue() + public async Task HasAccess_ProductNotLimitedToStores_ReturnsFalse() { var scope = new StoreAdminDataScope(_contextAccessor.Object); var product = new Product { LimitedToStores = false }; - Assert.IsTrue(await scope.HasAccess(product)); + Assert.IsFalse(await scope.HasAccess(product)); } [TestMethod] @@ -41,7 +50,7 @@ public async Task HasAccess_ProductLimitedToOtherStore_ReturnsFalse() } [TestMethod] - public async Task HasAccess_ProductLimitedToStaffStore_ReturnsTrue() + public async Task HasAccess_ProductLimitedToStaffStoreOnly_ReturnsTrue() { var scope = new StoreAdminDataScope(_contextAccessor.Object); var product = new Product { LimitedToStores = true, Stores = [StaffStoreId] }; @@ -49,6 +58,15 @@ public async Task HasAccess_ProductLimitedToStaffStore_ReturnsTrue() Assert.IsTrue(await scope.HasAccess(product)); } + [TestMethod] + public async Task HasAccess_ProductInMultipleStoresIncludingStaffStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + + Assert.IsFalse(await scope.HasAccess(product)); + } + [TestMethod] public void DefaultStoreId_ReturnsStaffStoreId() { diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs index a2087751f4..9105c91f9e 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -1,28 +1,27 @@ #nullable enable +using Grand.Domain; using Grand.Domain.Stores; using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Interfaces; namespace Grand.Web.AdminShared.Services; public class StoreAdminDataScope(IContextAccessor contextAccessor) : IAdminDataScope - where TEntity : IStoreLinkEntity + where TEntity : BaseEntity, IStoreLinkEntity { public Task HasAccess(TEntity entity) { - if (entity is null) return Task.FromResult(false); - var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); - return Task.FromResult(allowed); + return Task.FromResult(entity != null && entity.AccessToEntityByStore(staffStoreId)); } public IQueryable ApplyScope(IQueryable query) { var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; if (string.IsNullOrEmpty(staffStoreId)) return query; - return query.Where(x => !x.LimitedToStores || x.Stores.Contains(staffStoreId)); + return query.Where(x => x.LimitedToStores && x.Stores.Contains(staffStoreId) && x.Stores.Count == 1); } public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; From 3a5e4a78ca88748e2942e25d197850e2f5aa4937 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:35:38 +0200 Subject: [PATCH 010/147] Add VendorProductDataScope for the Vendor host (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Services/VendorProductDataScopeTests.cs | 76 +++++++++++++++++++ .../Services/VendorProductDataScope.cs | 26 +++++++ 2 files changed, 102 insertions(+) create mode 100644 src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs diff --git a/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs new file mode 100644 index 0000000000..cbbd948665 --- /dev/null +++ b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs @@ -0,0 +1,76 @@ +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Vendor.Tests.Services; + +[TestClass] +public class VendorProductDataScopeTests +{ + private Mock _contextAccessor = null!; + private const string VendorId = "vendor-1"; + + [TestInitialize] + public void Setup() + { + var workContext = new Mock(); + workContext.Setup(x => x.CurrentVendor).Returns(new Domain.Vendors.Vendor { Id = VendorId }); + _contextAccessor = new Mock(); + _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); + } + + [TestMethod] + public async Task HasAccess_OwnProduct_ReturnsTrue() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.HasAccess(new Product { VendorId = VendorId })); + } + + [TestMethod] + public async Task HasAccess_OtherVendorsProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(new Product { VendorId = "vendor-2" })); + } + + [TestMethod] + public async Task HasAccess_NullProduct_ReturnsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(await scope.HasAccess(null!)); + } + + [TestMethod] + public void ApplyScope_FiltersToOwnVendorId() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + var query = new[] + { + new Product { Id = "1", VendorId = VendorId }, + new Product { Id = "2", VendorId = "vendor-2" } + }.AsQueryable(); + + var result = scope.ApplyScope(query).ToList(); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual("1", result[0].Id); + } + + [TestMethod] + public void DefaultStoreId_IsNull() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public void ResourceKeyPrefix_IsVendor() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs new file mode 100644 index 0000000000..86e7cce3cf --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs @@ -0,0 +1,26 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +public class VendorProductDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(Product entity) + { + if (entity is null) return Task.FromResult(false); + return Task.FromResult(entity.VendorId == contextAccessor.WorkContext.CurrentVendor.Id); + } + + public IQueryable ApplyScope(IQueryable query) + { + var vendorId = contextAccessor.WorkContext.CurrentVendor.Id; + return query.Where(x => x.VendorId == vendorId); + } + + public string? DefaultStoreId => null; + + public string ResourceKeyPrefix => "Vendor"; +} From 083ed5224f952cff40038855f9c99fe30e70cfbd Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 15:41:54 +0200 Subject: [PATCH 011/147] Register IAdminDataScope in Admin, Store, Vendor hosts (ARCH-001 Phase 1) --- .../Startup/StartupApplication.cs | 2 ++ .../Startup/StartupApplication.cs | 21 +++++++++++++++++++ .../Startup/StartupApplication.cs | 2 ++ 3 files changed, 25 insertions(+) create mode 100644 src/Web/Grand.Web.Store/Startup/StartupApplication.cs diff --git a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs index a3093b18dc..4031d95093 100644 --- a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs @@ -1,5 +1,6 @@ using elFinder.Net.AspNetCore.Extensions; using elFinder.Net.Drivers.FileSystem.Extensions; +using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.Admin.Infrastructure; using Grand.Web.AdminShared.Interfaces; @@ -13,6 +14,7 @@ public class StartupApplication : IStartupApplication public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + services.AddScoped, GlobalAdminDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Store/Startup/StartupApplication.cs b/src/Web/Grand.Web.Store/Startup/StartupApplication.cs new file mode 100644 index 0000000000..d7103717d5 --- /dev/null +++ b/src/Web/Grand.Web.Store/Startup/StartupApplication.cs @@ -0,0 +1,21 @@ +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Services; + +namespace Grand.Web.Store.Startup; + +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped, StoreAdminDataScope>(); + } + + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) + { + } + + public int Priority => 101; + public bool BeforeConfigure => false; +} diff --git a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs index 92bbeb016b..f8759d4ec2 100644 --- a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs @@ -1,4 +1,5 @@ using Grand.Data; +using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.Vendor.Interfaces; using Grand.Web.Vendor.Services; @@ -12,6 +13,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config if (!DataSettingsManager.DatabaseIsInstalled()) return; + services.AddScoped, Grand.Web.AdminShared.Services.VendorProductDataScope>(); services.AddScoped(); services.AddScoped(); services.AddScoped(); From 36ffd59760179725881faca908cb173524d830e2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:04:21 +0200 Subject: [PATCH 012/147] Add BaseProductController with the list/create/edit/delete region (ARCH-001 Phase 1) Task 7 of ARCH-001 Phase 1: creates the abstract BaseProductController in Grand.Web.AdminShared, migrating only the first region ("Product list / create / edit / delete") as a worked template for Task 8's remaining 23 regions. Consumes IAdminDataScope (Tasks 1-4) to unify Admin's no-op access check, Store's AccessToEntityByStore check, and Vendor's HasAccessToProduct check into a single scope.HasAccess(product) call. Deliberate behavior change: GoToSku's access-denial path now redirects to List instead of Edit. Store's pre-refactor GoToSku had a bug where an access-denied SKU lookup fell through to redirect to Edit (bypassing the intended denial) while an access-granted lookup incorrectly showed a "not found" warning. The merged behavior fixes both: denied -> List, granted -> Edit. See the TODO(ARCH-001-followup) comment at the call site. Resource key prefix header comment carries forward Task 6's corrected 28-suffix audit (22 templated via scope.ResourceKeyPrefix, 6 Admin-only literals, 0 host-specific). Not yet wired to any host controller - BaseProductController is abstract and incomplete until Task 8 migrates the rest and Task 11 adds host subclasses. --- .../Controllers/BaseProductControllerTests.cs | 350 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 296 +++++++++++++++ 2 files changed, 646 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs create mode 100644 src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs new file mode 100644 index 0000000000..1730311be9 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -0,0 +1,350 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.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.Common.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +// Characterization tests for the merged access-check behavior in BaseProductController's +// "Product list / create / edit / delete" region (ARCH-001 Phase 1 Task 7). These replace the +// equivalent per-host access-check cases in Grand.Web.Admin/Store/Vendor.Tests ProductControllerTests +// (removed in Task 13), parameterized over a mocked IAdminDataScope instead of three +// different concrete access mechanisms (Admin: none: Store: AccessToEntityByStore; Vendor: HasAccessToProduct). +[TestClass] +public class BaseProductControllerTests +{ + // BaseProductController is abstract - it has no host until Task 11 subclasses it. This + // minimal subclass exists only so the actions under test can be invoked directly. + private class TestProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, + languageService, translationService, productReservationService, auctionService, + dateTimeService, permissionService, enumTranslationService, scope); + + private TestProductController _controller; + private Mock _productServiceMock; + private Mock _productViewModelServiceMock; + private Mock _translationServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); + AutoMapperConfig.Init(mapperConfig); + + _productServiceMock = new Mock(); + _productViewModelServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _controller = new TestProductController( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + new Mock().Object, + languageServiceMock.Object, + _translationServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + // --- Edit (GET) -------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditGet_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Edit("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditGet_ScopeDeniesAccess_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.Edit("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify( + s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task EditGet_ScopeGrantsAccess_ShowsForm() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.Edit("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductModel(It.IsAny(), product, false, false), Times.Once); + } + + // --- Edit (POST) ------------------------------------------------------------------------------- + + [TestMethod] + public async Task EditPost_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Edit(new ProductModel { Id = "missing" }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify( + s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_ScopeDeniesAccess_RedirectsToEditWithoutUpdating() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + _productViewModelServiceMock.Verify( + s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1", Ticks = 5 }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.UpdateProductModel(product, It.IsAny())) + .ReturnsAsync(product); + + var result = await _controller.Edit(new ProductModel { Id = "p1", Ticks = 5 }, continueEditing: false); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.UpdateProductModel(product, It.IsAny()), Times.Once); + } + + // --- Delete -------------------------------------------------------------------------------------- + + [TestMethod] + public async Task Delete_ProductNotFound_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); + + var result = await _controller.Delete("missing"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeDeniesAccess_RedirectsToEditWithoutDeleting() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.Delete("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); + } + + [TestMethod] + public async Task Delete_ScopeGrantsAccess_DeletesAndRedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.Delete("p1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); + } + + // --- CopyProduct ----------------------------------------------------------------------------------- + + [TestMethod] + public async Task CopyProduct_ScopeDeniesAccess_RedirectsToListWithoutCopying() + { + var originalProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(false); + var copyProductServiceMock = new Mock(); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy" } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + copyProductServiceMock.Verify( + s => s.CopyProduct(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CopyProduct_ScopeGrantsAccess_Copies() + { + var originalProduct = new Product { Id = "p1" }; + var newProduct = new Product { Id = "p2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(true); + var copyProductServiceMock = new Mock(); + copyProductServiceMock.Setup(s => s.CopyProduct(originalProduct, "copy", false)).ReturnsAsync(newProduct); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy", Published = false, CopyImages = false } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p2", redirect.RouteValues["id"]); + } + + // --- GoToSku ----------------------------------------------------------------------------------------- + // Deliberate behavior tightening vs. Store's pre-refactor GoToSku (see the TODO in + // BaseProductController.GoToSku): denial now redirects to List, not Edit. + + [TestMethod] + public async Task GoToSku_ProductNotFound_ShowsWarningAndRedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync((Product)null); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task GoToSku_ScopeDeniesAccess_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task GoToSku_ScopeGrantsAccess_RedirectsToEdit() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductBySku("sku1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p1", redirect.RouteValues["id"]); + } + + // --- List / Create default store-scoping ----------------------------------------------------------- + + [TestMethod] + public async Task List_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareProductListModel("store-1")).ReturnsAsync(new ProductListModel()); + + var result = await _controller.List(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductListModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task Create_Get_DefaultsModelStoreIdFromScope() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await _controller.Create() as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("store-1", model.StoreId); + } +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs new file mode 100644 index 0000000000..a20b0c64ea --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -0,0 +1,296 @@ +using Grand.Business.Core.Dto; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.StaticFiles; + +namespace Grand.Web.AdminShared.Controllers; + +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6, corrected after review): +// 28 unique "Admin."/"Vendor." GetResource(...) suffixes were found across +// Grand.Web.Admin/Controllers/ProductController.cs, Grand.Web.Store/Controllers/ProductController.cs, +// Grand.Web.Vendor/Controllers/ProductController.cs, Grand.Web.AdminShared/Services/ProductViewModelService.cs +// and Grand.Web.Vendor/Services/ProductViewModelService.cs (multi-line-aware extraction; the brief's +// original line-bound grep undercounted this at 23). Of the 28: +// - 22 are Templated: both an "Admin." and a "Vendor." call site exist, so these are +// safe to write as $"{scope.ResourceKeyPrefix}." - e.g. Catalog.Products.Added/Updated/Deleted, +// Catalog.Products.Fields.ChangedWarning, Catalog.Products.List.SkuNotFound, Common.All, Customers.Guest, +// and 15 more (see task-6-resource-prefix-table.md for the full list). +// - 6 are Admin-only literals with no Vendor call site (keep as literal "Admin.", do not +// template): Catalog.Products.Permissions, Catalog.Products.Imported, +// Catalog.Products.List.SearchPublished.ShowOnHomePage, Catalog.Products.TierPrices.Fields.CustomerGroup.All, +// Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. +// - 0 are host-specific-not-templated (every "Vendor." call site has a matching "Admin." +// one, so none needed a scope.ResourceKeyPrefix == "Vendor" special case). +// Store makes no separate resource lookups at all - every Store call site uses the literal "Admin.*" key +// directly, which is what scope.ResourceKeyPrefix == "Admin" (StoreAdminDataScope) already produces. +// Within this region (list/create/edit/delete/CopyProduct), every suffix used +// (Catalog.Products.List.SkuNotFound, Catalog.Products.Added, Catalog.Products.Updated, +// Catalog.Products.Deleted, Catalog.Products.Fields.ChangedWarning) is in the Templated set above. + +[PermissionAuthorize(PermissionSystemName.Products)] +public abstract class BaseProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + /// Hook for host-specific UI-copy warnings that aren't access-scope decisions. + /// Overridden by the Store subclass; no-op everywhere else. + protected virtual void EditWarningCheck(Product product) { } + + #region Product list / create / edit / delete + + public IActionResult Index() => RedirectToAction("List"); + + public async Task List() + { + var model = await productViewModelService.PrepareProductListModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task ProductList(DataSourceRequest command, ProductListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (productModels, totalCount) = + await productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); + return Json(new DataSourceResult { Data = productModels.ToList(), Total = totalCount }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task GoToSku(ProductListModel model) + { + var product = await productService.GetProductBySku(model.GoDirectlyToSku); + if (product != null) + { + // TODO(ARCH-001-followup): Store's pre-refactor code had a security-relevant bug here - + // on access denial it fell into `if (!CanAccessProduct(product)) return RedirectToAction("Edit", ...)`, + // i.e. it redirected an unauthorized caller straight to the Edit screen of a product outside + // their store. (On access *granted* the old code had a separate, non-security bug: it fell + // through to the "not found" Warning + redirect-to-List below instead of going to Edit.) + // The merged behavior below deliberately tightens this: deny -> List (matching Vendor's + // stricter pattern), grant -> Edit. This is an intentional behavior change, not a faithful + // port - call it out in the PR description. + if (!await scope.HasAccess(product)) + return RedirectToAction("List", "Product"); + return RedirectToAction("Edit", "Product", new { id = product.Id }); + } + + Warning(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SkuNotFound")); + return RedirectToAction("List", "Product"); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new ProductModel { StoreId = scope.DefaultStoreId }; + await productViewModelService.PrepareProductModel(model, null, true, true); + await AddLocales(languageService, model.Locales); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(ProductModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + var product = await productViewModelService.InsertProductModel(model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, null, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + + EditWarningCheck(product); + if (!await scope.HasAccess(product)) return RedirectToAction("List"); + + var model = product.ToModel(dateTimeService); + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, false); + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = product.GetTranslation(x => x.Name, languageId, false); + locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); + locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); + locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); + locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); + locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); + locale.SeName = product.GetSeName(languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(ProductModel model, bool continueEditing) + { + var product = await productService.GetProductById(model.Id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id = product.Id }); + + if (model.Ticks != product.Ticks) + { + Error(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Fields.ChangedWarning")); + return RedirectToAction("Edit", new { id = product.Id }); + } + + if (ModelState.IsValid) + { + if (scope.DefaultStoreId is not null) + { + model.Stores = [scope.DefaultStoreId]; + model.StoreId = scope.DefaultStoreId; + } + + product = await productViewModelService.UpdateProductModel(product, model); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = product.Id }); + } + + return RedirectToAction("List"); + } + + if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; + await productViewModelService.PrepareProductModel(model, product, false, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var product = await productService.GetProductById(id, true); + if (product == null) return RedirectToAction("List"); + if (!await scope.HasAccess(product)) return RedirectToAction("Edit", new { id }); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProduct(product); + Success(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task DeleteSelected(ICollection selectedIds) + { + if (selectedIds != null) await productViewModelService.DeleteSelected(selectedIds.ToList()); + return Json(new { Result = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + public async Task CopyProduct(ProductModel model, + [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) + { + var copyModel = model.CopyProductModel; + try + { + var originalProduct = await productService.GetProductById(copyModel.Id, true); + if (!await scope.HasAccess(originalProduct)) return RedirectToAction("List"); + + if (scope.DefaultStoreId is not null) + { + originalProduct.LimitedToStores = true; + originalProduct.Stores.Clear(); + originalProduct.Stores.Add(scope.DefaultStoreId); + } + + var newProduct = await copyProductService.CopyProduct(originalProduct, copyModel.Name, copyModel.Published); + if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); + + Success("The product has been copied successfully"); + return RedirectToAction("Edit", new { id = newProduct.Id }); + } + catch (Exception exc) + { + Error(exc.Message); + return RedirectToAction("Edit", new { id = copyModel.Id }); + } + } + + private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) + { + foreach (var productPicture in originalProduct.ProductPictures) + { + var picture = await pictureService.GetPictureById(productPicture.PictureId); + var pictureCopy = await pictureService.InsertPicture( + await pictureService.LoadPictureBinary(picture), + picture.MimeType, + pictureService.GetPictureSeName(newProduct.Name), + picture.AltAttribute, + picture.TitleAttribute, + false, + Reference.Product, + newProduct.Id); + + await productService.InsertProductPicture(new ProductPicture { + PictureId = pictureCopy.Id, + DisplayOrder = productPicture.DisplayOrder, + IsDefault = productPicture.IsDefault + }, newProduct.Id); + } + } + + #endregion +} From dcddc85be829ba5da8a65db63164c0b304fe6da6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:13:06 +0200 Subject: [PATCH 013/147] Plan fix: add IAdminDataScope.CanView, fix DeleteSelected scoping, inline Task 6 table Task 7's review found: (1) Critical - DeleteSelected shipped with no scope filter at all, handing Store host an unscoped bulk-delete endpoint once Task 11 subclasses it; (2) Important - HasAccess alone can't represent Store's actual Edit(GET)/CopyProduct behavior, which is deliberately looser than the strict mutation rule (existing test comment: 'the one path that must stay outside any shared authorize-or-redirect helper') - added CanView (default interface method, additive) with a Store override matching the original permissive rule; (3) Important - the resource-key table was only referenced via an untracked planning file - inlined the full 28-row table. Co-Authored-By: Claude Sonnet 5 --- ...16-arch001-product-consolidation-phase1.md | 119 ++++++++++++++++-- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index 1057124beb..aae10312a0 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -56,7 +56,9 @@ Skim each file's `using` block and test class setup (constructor mocks) — late - Create: `src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs` **Interfaces:** -- Produces: `IAdminDataScope` with `Task HasAccess(TEntity entity)`, `IQueryable ApplyScope(IQueryable query)`, `string? DefaultStoreId { get; }`, `string ResourceKeyPrefix { get; }` — consumed by Tasks 2-4 (implementations) and Task 7+ (`BaseProductController`)/Task 9+ (shared service). +- Produces: `IAdminDataScope` with `Task HasAccess(TEntity entity)`, `Task CanView(TEntity entity)` (default interface method, defaults to `HasAccess`), `IQueryable ApplyScope(IQueryable query)`, `string? DefaultStoreId { get; }`, `string ResourceKeyPrefix { get; }` — consumed by Tasks 2-4 (implementations) and Task 7+ (`BaseProductController`)/Task 9+ (shared service). + +**Addendum (added after Task 7's review found a real gap — see Task 7's fix round):** `CanView` was added retroactively as a default interface method, so this is additive/source-compatible — `GlobalAdminDataScope` and `VendorProductDataScope` (Tasks 2 and 4) need no change, only `StoreAdminDataScope` (Task 3) gets an override plus a test. - [ ] **Step 1: Write the interface** @@ -70,9 +72,18 @@ namespace Grand.Web.AdminShared.Interfaces; /// public interface IAdminDataScope { - /// Whether the current user may access this specific, already-loaded entity. + /// Whether the current user may mutate (edit/delete) this specific, already-loaded entity. + /// This is the strict check — for Store, matches AclMappingExtension.AccessToEntityByStore exactly + /// (denies global and multi-store entities, only the entity's exclusive single store passes). Task HasAccess(TEntity entity); + /// Whether the current user may view/reference this entity (open its edit form, copy it) — + /// looser than for hosts where viewing a shared/global entity is allowed but + /// mutating it isn't. Defaults to for hosts with no such split (Admin: always + /// true either way; Vendor: the two are identical, verified against the existing, unsplit + /// `CheckAccessToProduct`). Only Store overrides this (see Task 3 addendum). + Task CanView(TEntity entity) => HasAccess(entity); + /// Narrows a query to the entities the current user may see. No-op for global (Admin) scope. IQueryable ApplyScope(IQueryable query); @@ -351,6 +362,51 @@ git commit -m "Add StoreAdminDataScope for the Store host (ARCH-001 Phase 1)" **Note for Task 7/8:** the current `Grand.Web.Store/Controllers/ProductController.cs:88-92` `CanAccessProduct` helper (added in #786, uses `product.AccessToEntityByStore(staffStoreId)`) and this `HasAccess` implementation must agree. `AccessToEntityByStore` is the existing extension in `Grand.Business.Core.Extensions`; check its exact semantics against the `HasAccess` body above during Task 7 Step 1 and use whichever is authoritative (prefer calling the existing `AccessToEntityByStore` extension from inside `HasAccess` over reimplementing the same rule twice, if its signature fits `IStoreLinkEntity`). +**Addendum — `CanView` override (added after Task 7's review found this task's `HasAccess` alone can't represent Store's actual behavior):** + +`Grand.Web.Store/Controllers/ProductController.cs:184-194`'s `Edit(GET)` is NOT `HasAccess` plus a cosmetic warning — it is a materially looser rule that *replaces* the strict check for viewing: a global product or a multi-store product that includes the staff member's store is **allowed to view** (with a warning), and only a product limited to stores that exclude the staff member's store is denied. The existing test `Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs:248-264` (`EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning`) locks this in with an explicit comment: *"This is the one path that must stay outside any shared 'authorize or redirect' helper."* `Store/ProductController.cs:289-290`'s `CopyProduct` uses the same looser rule (denies only when `LimitedToStores && !Stores.Contains(staff)`). + +Add to `StoreAdminDataScope` (Task 3's file), after `HasAccess`: + +```csharp +public Task CanView(TEntity entity) +{ + if (entity is null) return Task.FromResult(false); + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); + return Task.FromResult(allowed); +} +``` + +Add tests to `StoreAdminDataScopeTests.cs`: + +```csharp +[TestMethod] +public async Task CanView_ProductNotLimitedToStores_ReturnsTrue() +{ + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.CanView(new Product { LimitedToStores = false })); +} + +[TestMethod] +public async Task CanView_ProductInMultipleStoresIncludingStaffStore_ReturnsTrue() +{ + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + Assert.IsTrue(await scope.CanView(product)); +} + +[TestMethod] +public async Task CanView_ProductLimitedToOtherStore_ReturnsFalse() +{ + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + Assert.IsFalse(await scope.CanView(product)); +} +``` + +Run `dotnet test src/Tests/Grand.Web.Store.Tests --filter "FullyQualifiedName~StoreAdminDataScopeTests"` — expect 9/9 passing. Commit alongside (or amend into) Task 3's existing commit is fine since this is a direct addendum to the same file/task, not a new task. + --- ## Task 4: `VendorProductDataScope` (Vendor host, Product-specific) @@ -630,7 +686,7 @@ Differences found, and how each is resolved: | `Edit()` GET extra "still has other stores" warning branch (Store only, lines 184-194) | absent | present | absent | Keep as a `protected virtual` no-op hook `EditWarningCheck(Product product)` overridden only in the Store subclass (Task 11) — this is host UI copy behavior, not scope logic, so it does not belong in `IAdminDataScope`. | | `PrepareProductModel(model, product, bool, bool)` arity | 4-arg | 4-arg | **3-arg** (no `excludeProperties`) | Resolved by Task 9 (interface unification) — until Task 9 lands, `BaseProductController` calls the 4-arg AdminShared signature with `excludeProperties: false` as Vendor's implicit default; verify against Vendor's actual usage (`false` in `Create()`, `true` in the redisplay-on-invalid branches) before assuming — re-check `src/Web/Grand.Web.Vendor/Controllers/ProductController.cs:139,157,174,222` line by line. | | Resource key prefix | `"Admin.*"` | `"Admin.*"` | `"Vendor.*"` | `$"{scope.ResourceKeyPrefix}.Catalog.Products.Added"` etc., per Task 6's table | -| `DeleteSelected` | present | absent (verify — grep confirms only Admin/Vendor define it; if Store truly has no `DeleteSelected` action, keep it but note the missing UI wiring is pre-existing and out of scope) | present | Keep the action in the base class; it's harmless if a host's view never posts to it | +| `DeleteSelected` | present, no controller-level filter | absent | present, no controller-level filter (Vendor's *service* — `Grand.Web.Vendor/Services/ProductViewModelService.cs:687` — filters per-id by `HasAccessToProduct`, but the controller doesn't) | **Not harmless** — MVC routes actions regardless of whether a host's views link to them, so shipping this unfiltered into the shared base hands Store a brand-new unscoped bulk-delete endpoint once Task 11 subclasses it, and leaves Vendor's only protection sitting in one host's service rather than the controller like every other guarded action here. Filter ids through `scope.HasAccess` in the base controller before delegating — see the code below. | - [ ] **Step 1a: File a note, don't fix, the Store `GoToSku` bug found above** @@ -669,7 +725,37 @@ using Microsoft.AspNetCore.StaticFiles; namespace Grand.Web.AdminShared.Controllers; -// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6): +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6, corrected after review — see Task 6's +// ledger entry). Inlined in full here (not just referenced) since planning artifacts under .superpowers/ +// are untracked and do not survive in the repo once this branch merges. +// +// Templated via {scope.ResourceKeyPrefix} (Admin. and Vendor. both exist) — 22: +// Common.All, Customers.Guest, Configuration.Tax.Settings.TaxCategories.None, +// Catalog.Products.Added, Catalog.Products.Updated, Catalog.Products.Deleted, +// Catalog.Products.Fields.ChangedWarning, Catalog.Products.Fields.DeliveryDate.None, +// Catalog.Products.Fields.Warehouse.None, Catalog.Products.Bids.CantDeleteWithOrder, +// Catalog.Products.List.SkuNotFound, Catalog.Products.List.SearchPublished.All, +// Catalog.Products.List.SearchPublished.PublishedOnly, Catalog.Products.List.SearchPublished.UnpublishedOnly, +// Catalog.Products.List.SearchPublished.MarkAsNew, Catalog.ProductReservations.CantDeleteWithOrder, +// Catalog.Products.Calendar.CannotChangeInterval, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue. +// +// Admin-only literal (no Vendor equivalent call site; keep as literal "Admin.") — 6: +// Catalog.Products.Permissions (Vendor has no Permissions-suffixed resource lookup anywhere - its +// permission-denied paths don't emit this message), Catalog.Products.List.SearchPublished.ShowOnHomePage, +// Catalog.Products.Imported, Catalog.Products.TierPrices.Fields.CustomerGroup.All, +// Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. +// +// Host-specific, not templated — 0: none found; every "Vendor." call site has a matching +// "Admin." one, so nothing needs a scope.ResourceKeyPrefix == "Vendor" guard instead of templating. +// +// Store makes no separate resource lookups at all - every Store call site uses the literal "Admin.*" key +// directly (Store has no distinct resource set), consistent with StoreAdminDataScope.ResourceKeyPrefix +// returning "Admin". [PermissionAuthorize(PermissionSystemName.Products)] public abstract class BaseProductController( @@ -766,7 +852,10 @@ public abstract class BaseProductController( if (product == null) return RedirectToAction("List"); EditWarningCheck(product); - if (!await scope.HasAccess(product)) return RedirectToAction("List"); + // CanView, not HasAccess: viewing a shared/global product is allowed on Store (with a warning + // from EditWarningCheck above); only mutating one is restricted to the exclusive single-store + // owner. See IAdminDataScope.CanView's doc comment and Task 3's addendum. + if (!await scope.CanView(product)) return RedirectToAction("List"); var model = product.ToModel(dateTimeService); if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; @@ -847,7 +936,20 @@ public abstract class BaseProductController( [HttpPost] public async Task DeleteSelected(ICollection selectedIds) { - if (selectedIds != null) await productViewModelService.DeleteSelected(selectedIds.ToList()); + if (selectedIds == null || selectedIds.Count == 0) return Json(new { Result = true }); + + // This is a mutation (bulk delete), so it uses the strict HasAccess, matching Edit(POST)/Delete + // above — not a no-op pass-through to the service. Without this filter, Store gains an + // unscoped bulk-delete endpoint (any store staff could delete any product id in the system, + // bypassing AccessToEntityByStore entirely) purely because MVC routes actions regardless of + // whether a host's views ever link to them. See Task 7's review for the full analysis. + var products = await productService.GetProductsByIds(selectedIds.ToArray(), true); + var allowedIds = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + allowedIds.Add(product.Id); + + if (allowedIds.Count > 0) await productViewModelService.DeleteSelected(allowedIds); return Json(new { Result = true }); } @@ -860,7 +962,10 @@ public abstract class BaseProductController( try { var originalProduct = await productService.GetProductById(copyModel.Id, true); - if (!await scope.HasAccess(originalProduct)) return RedirectToAction("List"); + // CanView, not HasAccess: Store's original CopyProduct denies only when LimitedToStores is + // true AND the staff member's store isn't among them — the same looser rule as Edit(GET) + // above, not the strict mutation rule. See IAdminDataScope.CanView. + if (!await scope.CanView(originalProduct)) return RedirectToAction("List"); if (scope.DefaultStoreId is not null) { From 35dd5d9892b3db2fac78022650bb5d057e2fa233 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:19:02 +0200 Subject: [PATCH 014/147] Fix Task 7 review findings: scope DeleteSelected, add CanView for Store's looser view/copy rule (ARCH-001 Phase 1) Applies the plan fix from commit dcddc85be (opus review of Task 7, confirmed independently by the coordinator): 1. Critical - DeleteSelected shipped with zero scope enforcement. Once Task 11 subclasses BaseProductController into the Store host, that was a brand-new unscoped bulk-delete endpoint (any store staff could delete any product id in the system via POST /Product/DeleteSelected). Fixed by loading the products via GetProductsByIds and filtering through scope.HasAccess (the strict mutation check, same one Edit(POST)/Delete already use) before delegating to the service. 2. Important - Edit(GET) and CopyProduct used the strict HasAccess, which is too strict for Store: the original Grand.Web.Store ProductController allows viewing/copying a global or multi-store product (including the staff member's store), only denying products limited to excluded stores. This is locked by an existing test with an explicit warning comment (Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs:248-264, 'the one path that must stay outside any shared authorize-or-redirect helper'). Added IAdminDataScope.CanView as a default interface method (additive, defaults to HasAccess - Global/Vendor scopes need no change) with a StoreAdminDataScope override implementing the looser rule. Edit(GET) and CopyProduct now gate on scope.CanView; Edit(POST)/Delete/ DeleteSelected remain on the strict scope.HasAccess since those are mutations. 3. Important - the resource-key-prefix table lived only in the untracked .superpowers/ planning file. Inlined the full corrected table as the header comment in BaseProductController.cs so it survives in the repo. New tests: StoreAdminDataScopeTests gets 3 CanView cases (9/9 total). BaseProductControllerTests gets DeleteSelected filtering coverage and CanView-vs-HasAccess regression guards for Edit(GET)/CopyProduct (21/21 total, up from 16). Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 104 ++++++++++++++++-- .../Services/StoreAdminDataScopeTests.cs | 28 +++++ .../Controllers/BaseProductController.cs | 75 +++++++++---- .../Interfaces/IAdminDataScope.cs | 11 +- .../Services/StoreAdminDataScope.cs | 12 ++ 5 files changed, 197 insertions(+), 33 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 1730311be9..0ead37cffb 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -99,15 +99,15 @@ public async Task EditGet_ProductNotFound_RedirectsToList() var redirect = result as RedirectToActionResult; Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + _scopeMock.Verify(s => s.CanView(It.IsAny()), Times.Never); } [TestMethod] - public async Task EditGet_ScopeDeniesAccess_RedirectsToList() + public async Task EditGet_ScopeDeniesView_RedirectsToList() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); var result = await _controller.Edit("p1"); @@ -120,11 +120,11 @@ public async Task EditGet_ScopeDeniesAccess_RedirectsToList() } [TestMethod] - public async Task EditGet_ScopeGrantsAccess_ShowsForm() + public async Task EditGet_ScopeGrantsView_ShowsForm() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); var result = await _controller.Edit("p1"); @@ -133,6 +133,23 @@ public async Task EditGet_ScopeGrantsAccess_ShowsForm() s => s.PrepareProductModel(It.IsAny(), product, false, false), Times.Once); } + [TestMethod] + public async Task EditGet_UsesCanViewNotHasAccess_LooserRuleWins() + { + // Regression guard for the review fix: Edit(GET) must gate on the looser CanView, not the + // strict mutation-only HasAccess. A product HasAccess would deny (e.g. Store's multi-store + // rule) but CanView allows must still show the form. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.Edit("p1"); + + Assert.IsInstanceOfType(result); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + // --- Edit (POST) ------------------------------------------------------------------------------- [TestMethod] @@ -232,11 +249,11 @@ public async Task Delete_ScopeGrantsAccess_DeletesAndRedirectsToList() // --- CopyProduct ----------------------------------------------------------------------------------- [TestMethod] - public async Task CopyProduct_ScopeDeniesAccess_RedirectsToListWithoutCopying() + public async Task CopyProduct_ScopeDeniesView_RedirectsToListWithoutCopying() { var originalProduct = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); - _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(false); var copyProductServiceMock = new Mock(); var model = new ProductModel { @@ -253,12 +270,12 @@ public async Task CopyProduct_ScopeDeniesAccess_RedirectsToListWithoutCopying() } [TestMethod] - public async Task CopyProduct_ScopeGrantsAccess_Copies() + public async Task CopyProduct_ScopeGrantsView_Copies() { var originalProduct = new Product { Id = "p1" }; var newProduct = new Product { Id = "p2" }; _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); - _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(true); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(true); var copyProductServiceMock = new Mock(); copyProductServiceMock.Setup(s => s.CopyProduct(originalProduct, "copy", false)).ReturnsAsync(newProduct); @@ -274,6 +291,75 @@ public async Task CopyProduct_ScopeGrantsAccess_Copies() Assert.AreEqual("p2", redirect.RouteValues["id"]); } + [TestMethod] + public async Task CopyProduct_UsesCanViewNotHasAccess_LooserRuleWins() + { + // Regression guard: CopyProduct must gate on CanView (Store's original rule: denies only + // when LimitedToStores excludes the staff store), not the strict HasAccess. + var originalProduct = new Product { Id = "p1" }; + var newProduct = new Product { Id = "p2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(originalProduct); + _scopeMock.Setup(s => s.HasAccess(originalProduct)).ReturnsAsync(false); + _scopeMock.Setup(s => s.CanView(originalProduct)).ReturnsAsync(true); + var copyProductServiceMock = new Mock(); + copyProductServiceMock.Setup(s => s.CopyProduct(originalProduct, "copy", false)).ReturnsAsync(newProduct); + + var model = new ProductModel { + CopyProductModel = new CopyProductModel { Id = "p1", Name = "copy", Published = false, CopyImages = false } + }; + + var result = await _controller.CopyProduct(model, copyProductServiceMock.Object, new Mock().Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("Edit", redirect.ActionName); + Assert.AreEqual("p2", redirect.RouteValues["id"]); + } + + // --- DeleteSelected ------------------------------------------------------------------------------ + + [TestMethod] + public async Task DeleteSelected_NoIds_DoesNotCallService() + { + var result = await _controller.DeleteSelected(new List()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + _productViewModelServiceMock.Verify(s => s.DeleteSelected(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task DeleteSelected_FiltersOutProductsScopeDenies() + { + // Regression guard for the review fix: DeleteSelected is a mutation, so it must filter through + // the strict HasAccess before delegating - without this, Store would gain an unscoped + // bulk-delete endpoint (any staff could delete any product id in the system). + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.DeleteSelected(new List { "owned", "foreign" }); + + _productViewModelServiceMock.Verify( + s => s.DeleteSelected(It.Is>(ids => ids.Count == 1 && ids[0] == "owned")), Times.Once); + } + + [TestMethod] + public async Task DeleteSelected_AllProductsScopeDenies_DoesNotCallDeleteSelected() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.DeleteSelected(new List { "foreign" }); + + _productViewModelServiceMock.Verify(s => s.DeleteSelected(It.IsAny>()), Times.Never); + } + // --- GoToSku ----------------------------------------------------------------------------------------- // Deliberate behavior tightening vs. Store's pre-refactor GoToSku (see the TODO in // BaseProductController.GoToSku): denial now redirects to List, not Edit. diff --git a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs index 83ccf508f6..0b3f56b478 100644 --- a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs @@ -82,4 +82,32 @@ public void ResourceKeyPrefix_IsAdmin() var scope = new StoreAdminDataScope(_contextAccessor.Object); Assert.AreEqual("Admin", scope.ResourceKeyPrefix); } + + // CanView is deliberately looser than HasAccess: it mirrors Store's original Edit(GET)/CopyProduct + // rule (a global or multi-store product including the staff member's store may be viewed/copied; + // only a product limited to stores that exclude the staff member's store is denied). See the + // existing test comment in ProductControllerTests.EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning: + // "This is the one path that must stay outside any shared 'authorize or redirect' helper." + [TestMethod] + public async Task CanView_ProductNotLimitedToStores_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(await scope.CanView(new Product { LimitedToStores = false })); + } + + [TestMethod] + public async Task CanView_ProductInMultipleStoresIncludingStaffStore_ReturnsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = [StaffStoreId, "store-3"] }; + Assert.IsTrue(await scope.CanView(product)); + } + + [TestMethod] + public async Task CanView_ProductLimitedToOtherStore_ReturnsFalse() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + var product = new Product { LimitedToStores = true, Stores = ["store-2"] }; + Assert.IsFalse(await scope.CanView(product)); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index a20b0c64ea..4f1ba75a40 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -28,27 +28,37 @@ namespace Grand.Web.AdminShared.Controllers; -// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6, corrected after review): -// 28 unique "Admin."/"Vendor." GetResource(...) suffixes were found across -// Grand.Web.Admin/Controllers/ProductController.cs, Grand.Web.Store/Controllers/ProductController.cs, -// Grand.Web.Vendor/Controllers/ProductController.cs, Grand.Web.AdminShared/Services/ProductViewModelService.cs -// and Grand.Web.Vendor/Services/ProductViewModelService.cs (multi-line-aware extraction; the brief's -// original line-bound grep undercounted this at 23). Of the 28: -// - 22 are Templated: both an "Admin." and a "Vendor." call site exist, so these are -// safe to write as $"{scope.ResourceKeyPrefix}." - e.g. Catalog.Products.Added/Updated/Deleted, -// Catalog.Products.Fields.ChangedWarning, Catalog.Products.List.SkuNotFound, Common.All, Customers.Guest, -// and 15 more (see task-6-resource-prefix-table.md for the full list). -// - 6 are Admin-only literals with no Vendor call site (keep as literal "Admin.", do not -// template): Catalog.Products.Permissions, Catalog.Products.Imported, -// Catalog.Products.List.SearchPublished.ShowOnHomePage, Catalog.Products.TierPrices.Fields.CustomerGroup.All, -// Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. -// - 0 are host-specific-not-templated (every "Vendor." call site has a matching "Admin." -// one, so none needed a scope.ResourceKeyPrefix == "Vendor" special case). +// Resource-key-prefix audit (2026-08-16, ARCH-001 Phase 1 Task 6, corrected after review — see Task 6's +// ledger entry). Inlined in full here (not just referenced) since planning artifacts under .superpowers/ +// are untracked and do not survive in the repo once this branch merges. +// +// Templated via {scope.ResourceKeyPrefix} (Admin. and Vendor. both exist) — 22: +// Common.All, Customers.Guest, Configuration.Tax.Settings.TaxCategories.None, +// Catalog.Products.Added, Catalog.Products.Updated, Catalog.Products.Deleted, +// Catalog.Products.Fields.ChangedWarning, Catalog.Products.Fields.DeliveryDate.None, +// Catalog.Products.Fields.Warehouse.None, Catalog.Products.Bids.CantDeleteWithOrder, +// Catalog.Products.List.SkuNotFound, Catalog.Products.List.SearchPublished.All, +// Catalog.Products.List.SearchPublished.PublishedOnly, Catalog.Products.List.SearchPublished.UnpublishedOnly, +// Catalog.Products.List.SearchPublished.MarkAsNew, Catalog.ProductReservations.CantDeleteWithOrder, +// Catalog.Products.Calendar.CannotChangeInterval, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize, +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue. +// +// Admin-only literal (no Vendor equivalent call site; keep as literal "Admin.") — 6: +// Catalog.Products.Permissions (Vendor has no Permissions-suffixed resource lookup anywhere - its +// permission-denied paths don't emit this message), Catalog.Products.List.SearchPublished.ShowOnHomePage, +// Catalog.Products.Imported, Catalog.Products.TierPrices.Fields.CustomerGroup.All, +// Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. +// +// Host-specific, not templated — 0: none found; every "Vendor." call site has a matching +// "Admin." one, so nothing needs a scope.ResourceKeyPrefix == "Vendor" guard instead of templating. +// // Store makes no separate resource lookups at all - every Store call site uses the literal "Admin.*" key -// directly, which is what scope.ResourceKeyPrefix == "Admin" (StoreAdminDataScope) already produces. -// Within this region (list/create/edit/delete/CopyProduct), every suffix used -// (Catalog.Products.List.SkuNotFound, Catalog.Products.Added, Catalog.Products.Updated, -// Catalog.Products.Deleted, Catalog.Products.Fields.ChangedWarning) is in the Templated set above. +// directly (Store has no distinct resource set), consistent with StoreAdminDataScope.ResourceKeyPrefix +// returning "Admin". [PermissionAuthorize(PermissionSystemName.Products)] public abstract class BaseProductController( @@ -153,7 +163,10 @@ public async Task Edit(string id) if (product == null) return RedirectToAction("List"); EditWarningCheck(product); - if (!await scope.HasAccess(product)) return RedirectToAction("List"); + // CanView, not HasAccess: viewing a shared/global product is allowed on Store (with a warning + // from EditWarningCheck above); only mutating one is restricted to the exclusive single-store + // owner. See IAdminDataScope.CanView's doc comment and StoreAdminDataScope.CanView. + if (!await scope.CanView(product)) return RedirectToAction("List"); var model = product.ToModel(dateTimeService); if (scope.DefaultStoreId is not null) model.StoreId = scope.DefaultStoreId; @@ -234,7 +247,20 @@ public async Task Delete(string id) [HttpPost] public async Task DeleteSelected(ICollection selectedIds) { - if (selectedIds != null) await productViewModelService.DeleteSelected(selectedIds.ToList()); + if (selectedIds == null || selectedIds.Count == 0) return Json(new { Result = true }); + + // This is a mutation (bulk delete), so it uses the strict HasAccess, matching Edit(POST)/Delete + // above — not a no-op pass-through to the service. Without this filter, Store gains an + // unscoped bulk-delete endpoint (any store staff could delete any product id in the system, + // bypassing AccessToEntityByStore entirely) purely because MVC routes actions regardless of + // whether a host's views ever link to them. + var products = await productService.GetProductsByIds(selectedIds.ToArray(), true); + var allowedIds = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + allowedIds.Add(product.Id); + + if (allowedIds.Count > 0) await productViewModelService.DeleteSelected(allowedIds); return Json(new { Result = true }); } @@ -247,7 +273,10 @@ public async Task CopyProduct(ProductModel model, try { var originalProduct = await productService.GetProductById(copyModel.Id, true); - if (!await scope.HasAccess(originalProduct)) return RedirectToAction("List"); + // CanView, not HasAccess: Store's original CopyProduct denies only when LimitedToStores is + // true AND the staff member's store isn't among them — the same looser rule as Edit(GET) + // above, not the strict mutation rule. See IAdminDataScope.CanView. + if (!await scope.CanView(originalProduct)) return RedirectToAction("List"); if (scope.DefaultStoreId is not null) { diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs index e64f6b3815..9f50b84ab2 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -9,9 +9,18 @@ namespace Grand.Web.AdminShared.Interfaces; /// public interface IAdminDataScope { - /// Whether the current user may access this specific, already-loaded entity. + /// Whether the current user may mutate (edit/delete) this specific, already-loaded entity. + /// This is the strict check — for Store, matches AclMappingExtension.AccessToEntityByStore exactly + /// (denies global and multi-store entities, only the entity's exclusive single store passes). Task HasAccess(TEntity entity); + /// Whether the current user may view/reference this entity (open its edit form, copy it) — + /// looser than for hosts where viewing a shared/global entity is allowed but + /// mutating it isn't. Defaults to for hosts with no such split (Admin: always + /// true either way; Vendor: the two are identical, verified against the existing, unsplit + /// `CheckAccessToProduct`). Only Store overrides this. + Task CanView(TEntity entity) => HasAccess(entity); + /// Narrows a query to the entities the current user may see. No-op for global (Admin) scope. IQueryable ApplyScope(IQueryable query); diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs index 9105c91f9e..6e5848922b 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -17,6 +17,18 @@ public Task HasAccess(TEntity entity) return Task.FromResult(entity != null && entity.AccessToEntityByStore(staffStoreId)); } + /// Looser than : a global entity or one shared across multiple + /// stores (including the staff member's) may be viewed; only an entity limited to stores that + /// exclude the staff member's store is denied. Mirrors Store's original Edit(GET)/CopyProduct + /// behavior (see Grand.Web.Store's ProductController pre-consolidation). + public Task CanView(TEntity entity) + { + if (entity is null) return Task.FromResult(false); + var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + var allowed = !entity.LimitedToStores || entity.Stores.Contains(staffStoreId); + return Task.FromResult(allowed); + } + public IQueryable ApplyScope(IQueryable query) { var staffStoreId = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; From a8a0574506755c7455ffdf0677cb0700a0445fb3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:25:57 +0200 Subject: [PATCH 015/147] Migrate 'Required products' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 118 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 66 ++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 0ead37cffb..0c55f82387 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -433,4 +433,122 @@ public async Task Create_Get_DefaultsModelStoreIdFromScope() Assert.IsNotNull(model); Assert.AreEqual("store-1", model.StoreId); } + + // --- LoadProductFriendlyNames -------------------------------------------------------------------- + // Filters the display list rather than denying the whole request: matches Store's CanAccessProduct + // loop and Vendor's HasAccessToProduct loop, both of which skip inaccessible products silently. + + [TestMethod] + public async Task LoadProductFriendlyNames_EmptyInput_ReturnsEmptyText() + { + var result = await _controller.LoadProductFriendlyNames(""); + + var json = result as JsonResult; + Assert.IsNotNull(json); + Assert.AreEqual("", GetTextProperty(json.Value)); + _productServiceMock.Verify(s => s.GetProductsByIds(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task LoadProductFriendlyNames_ScopeDeniesAccess_SkipsProduct() + { + var allowed = new Product { Id = "p1", Name = "Allowed" }; + var denied = new Product { Id = "p2", Name = "Denied" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "p1", "p2" }, true)) + .ReturnsAsync(new List { allowed, denied }); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + + var result = await _controller.LoadProductFriendlyNames("p1,p2"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + // Faithful port of Store/Vendor's original index-based comma logic: it decides whether to + // append ", " from the loop index (i != products.Count - 1), not from whether a name was + // actually appended, so skipping the last product still leaves a trailing ", ". Pre-existing + // quirk in both original hosts, not introduced by this migration - characterized, not fixed. + Assert.AreEqual("Allowed, ", GetTextProperty(json.Value)); + } + + [TestMethod] + public async Task LoadProductFriendlyNames_ScopeGrantsAccess_IncludesAllProducts() + { + var p1 = new Product { Id = "p1", Name = "First" }; + var p2 = new Product { Id = "p2", Name = "Second" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "p1", "p2" }, true)) + .ReturnsAsync(new List { p1, p2 }); + _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true); + + var result = await _controller.LoadProductFriendlyNames("p1,p2"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + Assert.AreEqual("First, Second", GetTextProperty(json.Value)); + } + + private static string GetTextProperty(object value) => + (string)value.GetType().GetProperty("Text")!.GetValue(value); + + // --- RequiredProductAddPopup ---------------------------------------------------------------------- + + [TestMethod] + public async Task RequiredProductAddPopup_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddRequiredProductModel()); + + var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel("store-1"), Times.Once); + Assert.AreEqual("input1", _controller.ViewBag.productIdsInput); + } + + [TestMethod] + public async Task RequiredProductAddPopup_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel("")) + .ReturnsAsync(new ProductModel.AddRequiredProductModel()); + + var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(""), Times.Once); + } + + // --- RequiredProductAddPopupList ------------------------------------------------------------------- + + [TestMethod] + public async Task RequiredProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRequiredProductModel(); + var result = await _controller.RequiredProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRequiredProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RequiredProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 4f1ba75a40..655c5966fa 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -322,4 +322,70 @@ await productService.InsertProductPicture(new ProductPicture { } #endregion + + #region Required products + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task LoadProductFriendlyNames(string productIds) + { + var result = ""; + + if (!string.IsNullOrWhiteSpace(productIds)) + { + var ids = productIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .ToList(); + + var products = await productService.GetProductsByIds(ids.ToArray(), true); + for (var i = 0; i <= products.Count - 1; i++) + { + // Filters the friendly-name list, not a hard deny of the whole action: matches Store's + // CanAccessProduct loop and Vendor's HasAccessToProduct loop, both of which skip + // inaccessible products silently rather than erroring the whole request. Both are the + // strict rule (AccessToEntityByStore / VendorId equality), so HasAccess (not CanView) is + // correct here - this is filtering a display list, not opening/copying a single entity. + if (!await scope.HasAccess(products[i])) continue; + + result += products[i].Name; + if (i != products.Count - 1) + result += ", "; + } + } + + return Json(new { Text = result }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RequiredProductAddPopup(string productIdsInput) + { + // scope.DefaultStoreId already encodes the per-host default exactly: null for Admin (global) and + // Vendor (not store-scoped), StaffStoreId for Store - matching Store's original + // PrepareAddRequiredProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareAddRequiredProductModel(scope.DefaultStoreId ?? ""); + // Unused by any of the three views (all three read productIdsInput straight off the query string + // via Context.Request.Query, not ViewBag), but Admin and Vendor both set it and Store silently + // drops its own parameter - kept here for parity; it is inert either way. + ViewBag.productIdsInput = productIdsInput; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RequiredProductAddPopupList(DataSourceRequest command, + ProductModel.AddRequiredProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + #endregion } From 329c786982d4fc0ccd6a7e510e849e2e999c001a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:30:43 +0200 Subject: [PATCH 016/147] Migrate 'Product categories' region into BaseProductController (ARCH-001 Phase 1) Fix (post-review): template the Catalog.Products.Permissions denial message via scope.ResourceKeyPrefix instead of hardcoding "Admin.". Task 6's audit only scanned the files under migration and never saw a Vendor call site for this key, but Vendor.Catalog.Products.Permissions genuinely exists at the XML resource layer (en_220.xml, consumed by Grand.Web.Vendor's validators) - that audit's scope was narrower than "Admin-only". --- .../Controllers/BaseProductControllerTests.cs | 151 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 113 ++++++++++++- 2 files changed, 259 insertions(+), 5 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 0c55f82387..a462df26ca 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -551,4 +551,155 @@ public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideMo Assert.IsInstanceOfType(result); Assert.AreEqual("explicit", model.SearchStoreId); } + + // --- ProductCategoryList ---------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. + + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + } + + // Guards against a real regression found in review: the denial message must be templated via + // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists + // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a + // Vendor call site for it. + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCategoryInsert --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 655c5966fa..bc16a018f5 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -32,7 +32,7 @@ namespace Grand.Web.AdminShared.Controllers; // ledger entry). Inlined in full here (not just referenced) since planning artifacts under .superpowers/ // are untracked and do not survive in the repo once this branch merges. // -// Templated via {scope.ResourceKeyPrefix} (Admin. and Vendor. both exist) — 22: +// Templated via {scope.ResourceKeyPrefix} (Admin. and Vendor. both exist) — 23: // Common.All, Customers.Guest, Configuration.Tax.Settings.TaxCategories.None, // Catalog.Products.Added, Catalog.Products.Updated, Catalog.Products.Deleted, // Catalog.Products.Fields.ChangedWarning, Catalog.Products.Fields.DeliveryDate.None, @@ -45,11 +45,19 @@ namespace Grand.Web.AdminShared.Controllers; // Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength, // Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions, // Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize, -// Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue. +// Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue, +// Catalog.Products.Permissions (CORRECTED 2026-08-16, Task 8 row "Product categories": this row's +// original pass kept it as an "Admin-only literal" below, trusting Task 6's audit - but that audit +// only scanned the 5 files under migration [2 ProductControllers + 2 ProductViewModelServices], never +// validators. "Vendor.Catalog.Products.Permissions" genuinely exists in +// src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml and is consumed by +// Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs and BundleProductModelValidator.cs. +// Lesson for later rows: "no call site found in the files under migration" is NOT the same claim as +// "no resource key exists for Vendor" - check the XML resource files too before treating a key as +// host-specific). // -// Admin-only literal (no Vendor equivalent call site; keep as literal "Admin.") — 6: -// Catalog.Products.Permissions (Vendor has no Permissions-suffixed resource lookup anywhere - its -// permission-denied paths don't emit this message), Catalog.Products.List.SearchPublished.ShowOnHomePage, +// Admin-only literal (no Vendor equivalent call site; keep as literal "Admin.") — 5: +// Catalog.Products.List.SearchPublished.ShowOnHomePage, // Catalog.Products.Imported, Catalog.Products.TierPrices.Fields.CustomerGroup.All, // Catalog.Products.TierPrices.Fields.Store.All, Common.UploadFile. // @@ -388,4 +396,99 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c } #endregion + + #region Product categories + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCategoryList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same + // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's + // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting + // any vendor mutate another vendor's product-category mappings by id. + if (!await scope.HasAccess(product)) + // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header + // comment above) only covered the files under migration (the 2 ProductControllers + 2 + // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's + // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the + // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by + // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was + // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); + var gridModel = new DataSourceResult { + Data = productCategoriesModel, + Total = productCategoriesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From 2490c4b9ae9dea8ee66c0b3ad7e6acc31fe6deaa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:42:34 +0200 Subject: [PATCH 017/147] Migrate 'Product collections' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 150 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 89 +++++++++++ 2 files changed, 239 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index a462df26ca..309e6b2873 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -702,4 +702,154 @@ public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); } + + // --- ProductCollectionList ------------------------------------------------------------------ + // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and + // Vendor's CheckAccessToProduct gating this action on both hosts. + + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + } + + // Guards against the same regression class found in "Product categories": the denial message must be + // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" + // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCollectionInsert ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); + } + + // --- ProductCollectionUpdate ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); + } + + // --- ProductCollectionDelete ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index bc16a018f5..980ac2f89d 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -491,4 +491,93 @@ public async Task ProductCategoryDelete(ProductModel.ProductCateg } #endregion + + #region Product collections + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCollectionList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Product categories" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying + // it uniformly also closes the same kind of gap found in "Product categories": Store's and + // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all + // - only List checked - letting any store manager or vendor mutate another party's + // product-collection mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); + var gridModel = new DataSourceResult { + Data = productCollectionsModel, + Total = productCollectionsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From 81f64b27fcaaec5fa664071196cc9cb2f21a9829 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:49:09 +0200 Subject: [PATCH 018/147] Migrate 'Related products' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 Merges RelatedProductList/Update/Delete/AddPopup(GET+POST)/AddPopupList from Admin/Store/Vendor ProductController into BaseProductController, resolved through IAdminDataScope. - List/Update/Delete/AddPopup(POST) now gated by scope.HasAccess (strict), templated via scope.ResourceKeyPrefix. This closes a real IDOR: Vendor's original Update/Delete/AddPopup(POST) had zero ownership checks - only List was gated - letting any vendor mutate or attach related-product mappings on another vendor's product by id. - AddPopup(GET) and AddPopupList keep the no-check / SearchStoreId-scoping behavior common to all three original hosts. - PrepareRelatedProductModel called with scope.DefaultStoreId ?? "" per the established pattern (old storeId-parameter signature still in place; Task 9 removes it later). - Added InvalidRelatedProductAddPopupResult protected virtual hook: Admin and Store both re-prepare + return View on invalid ModelState; Vendor instead returns Content(ModelState.GetErrors()), a Vendor-only extension AdminShared cannot reference. Default matches Admin/Store; documented for a future Vendor subclass override once hosts are subclassed (Task 11). Tests: 18 new cases in BaseProductControllerTests covering granted/denied scope checks for List/Update/Delete/AddPopup(POST), plus DefaultStoreId plumbing for AddPopup(GET)/AddPopupList. 59/59 passing. --- .../Controllers/BaseProductControllerTests.cs | 217 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 134 +++++++++++ 2 files changed, 351 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 309e6b2873..c9cac27c74 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -852,4 +852,221 @@ public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); } + + // --- RelatedProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. + + [TestMethod] + public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RelatedProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddRelatedProductModel()); + + var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRelatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task RelatedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("")) + .ReturnsAsync(new ProductModel.AddRelatedProductModel()); + + var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(""), Times.Once); + } + + // --- RelatedProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel(); + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach related-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddRelatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RelatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 980ac2f89d..4411b1aa35 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -580,4 +580,138 @@ public async Task ProductCollectionDelete(ProductModel.ProductCol } #endregion + + #region Related products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RelatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Product categories"/"Product collections" - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: + // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at + // all - only List checked - letting any vendor mutate another vendor's related-product mappings + // by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); + var relatedProductsModel = new List(); + foreach (var x in relatedProducts) + relatedProductsModel.Add(new ProductModel.RelatedProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = relatedProductsModel, + Total = relatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RelatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareRelatedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareRelatedProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRelatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); + return Content(""); + } + + return await InvalidRelatedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRelatedProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + #endregion } From a13edc351f99c8299444ab3425c64e63e6aef6e3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 16:55:35 +0200 Subject: [PATCH 019/147] Migrate 'Similar products' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 217 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 133 +++++++++++ 2 files changed, 350 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index c9cac27c74..bf3cc798b9 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1069,4 +1069,221 @@ public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); } + + // --- SimilarProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList above. + + [TestMethod] + public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- SimilarProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + + var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddSimilarProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task SimilarProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("")) + .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + + var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(""), Times.Once); + } + + // --- SimilarProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel(); + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddSimilarProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.SimilarProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 4411b1aa35..9fc59a236f 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -714,4 +714,137 @@ protected virtual async Task InvalidRelatedProductAddPopupResult( } #endregion + + #region Similar products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task SimilarProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products" above - mirrors Store's + // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's + // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - + // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's + // similar-product mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); + var similarProductsModel = new List(); + foreach (var x in similarProducts) + similarProductsModel.Add(new ProductModel.SimilarProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = similarProductsModel, + Total = similarProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SimilarProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareSimilarProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareSimilarProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopupList(DataSourceRequest command, + ProductModel.AddSimilarProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); + return Content(""); + } + + return await InvalidSimilarProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareSimilarProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + #endregion } From 8ae78a630ea49d95116d856d70e1cef5a74ae3da Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 17:02:10 +0200 Subject: [PATCH 020/147] Migrate 'Bundle products' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 217 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 133 +++++++++++ 2 files changed, 350 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index bf3cc798b9..df1524a239 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1286,4 +1286,221 @@ public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); } + + // --- BundleProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. + + [TestMethod] + public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- BundleProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); + } + + // --- BundleProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); + } + + // --- BundleProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); + + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddBundleProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task BundleProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("")) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); + + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(""), Times.Once); + } + + // --- BundleProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel(); + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- BundleProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddBundleProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.BundleProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 9fc59a236f..1819a26d43 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -847,4 +847,137 @@ protected virtual async Task InvalidSimilarProductAddPopupResult( } #endregion + + #region Bundle products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task BundleProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products"/"Similar products" above - + // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the + // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ + // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via + // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); + var bundleProductsModel = new List(); + foreach (var x in bundleProducts) + bundleProductsModel.Add(new ProductModel.BundleProductModel { + Id = x.Id, + ProductBundleId = productId, + ProductId = x.ProductId, + ProductName = (await productService.GetProductById(x.ProductId))?.Name, + DisplayOrder = x.DisplayOrder, + Quantity = x.Quantity + }); + var gridModel = new DataSourceResult { + Data = bundleProductsModel, + Total = bundleProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductUpdate(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductDelete(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task BundleProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareBundleProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareBundleProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopupList(DataSourceRequest command, + ProductModel.AddBundleProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings + // onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); + return Content(""); + } + + return await InvalidBundleProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareBundleProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + #endregion } From fbae675bc351518dbf86b05b094bdff7b80735be Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 17:08:10 +0200 Subject: [PATCH 021/147] Migrate 'Cross-sell products' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 211 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 122 ++++++++++ 2 files changed, 333 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index df1524a239..f9999d1be5 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1503,4 +1503,215 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retur Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); } + + // --- CrossSellProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's + // original CrossSellProductList had no check at all. + + [TestMethod] + public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- CrossSellProductDelete ------------------------------------------------------------------- + // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original + // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task CrossSellProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); + } + + // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); + + var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddCrossSellProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("")) + .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); + + var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(""), Times.Once); + } + + // --- CrossSellProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel(); + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach cross-sell-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddCrossSellProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.CrossSellProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 1819a26d43..aba501ce9c 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -980,4 +980,126 @@ protected virtual async Task InvalidBundleProductAddPopupResult(P } #endregion + + #region Cross-sell products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CrossSellProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Related products"/"Bundle products" above - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly + // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for + // Admin's scope). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProducts = product.CrossSellProduct; + var crossSellProductsModel = new List(); + foreach (var x in crossSellProducts) + crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = crossSellProductsModel, + Total = crossSellProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's + // cross-sell-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(crossSellProduct)) + throw new ArgumentException("No cross-sell product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CrossSellProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareCrossSellProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareCrossSellProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopupList(DataSourceRequest command, + ProductModel.AddCrossSellProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); + return Content(""); + } + + return await InvalidCrossSellProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareCrossSellProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + #endregion } From fad7cc33ce28c4c6851aeb6fd2cc17de4cb18a9a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 17:13:12 +0200 Subject: [PATCH 022/147] Migrate 'Recommended products' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 212 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 122 ++++++++++ 2 files changed, 334 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index f9999d1be5..3f51ccb35e 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1714,4 +1714,216 @@ public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Re Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); } + + // --- RecommendedProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original + // RecommendedProductList had no check at all. Vendor's original signature also dropped the + // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. + + [TestMethod] + public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RecommendedProductDelete ------------------------------------------------------------------- + // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original + // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task RecommendedProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); + } + + // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); + + var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRecommendedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("")) + .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); + + var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(""), Times.Once); + } + + // --- RecommendedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel(); + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach recommended-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddRecommendedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RecommendedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index aba501ce9c..28ce74b394 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1102,4 +1102,126 @@ protected virtual async Task InvalidCrossSellProductAddPopupResul } #endregion + + #region Recommended products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RecommendedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: same shape as "Cross-sell products" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's + // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes + // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). + // Vendor's original signature also dropped the DataSourceRequest command parameter entirely + // (unused by the body on any host either way) - kept here for parity with Admin/Store. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProductsModel = new List(); + foreach (var x in product.RecommendedProduct) + recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = recommendedProductsModel, + Total = recommendedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's + // recommended-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(recommendedProduct)) + throw new ArgumentException("No recommended product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RecommendedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareRecommendedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareRecommendedProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRecommendedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); + return Content(""); + } + + return await InvalidRecommendedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRecommendedProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + #endregion } From e8d2bc760fb869c5c492a143379b57772d1e6b60 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 20:46:46 +0200 Subject: [PATCH 023/147] Migrate 'Associated products' region into BaseProductController (ARCH-001 Phase 1) - Merges AssociatedProductList/Update/Delete/AddPopup(GET+POST)/AddPopupList from Admin/Store/Vendor ProductControllers into BaseProductController, gating all mutating actions through scope.HasAccess. - Fixes a real gap: Vendor's controller had no ownership check on the parent product (model.ProductId) for AssociatedProductAddPopup(POST), in either layer. Vendor's own host-specific service (Grand.Web.Vendor/Services/ ProductViewModelService.cs InsertAssociatedProductModel) already filtered each selected product via HasAccessToProduct before reparenting it, but the parent was never checked anywhere - letting a vendor attach their own products under another vendor's grouped product (cross-vendor storefront pollution / data-integrity issue, not a cross-tenant write to someone else's product record). Store's original already filtered selected ids at the controller level for the same reparenting reason (InsertAssociated- ProductModel mutates the selected products' own records, unlike Related/ Similar/Bundle/Cross-sell/Recommended); that per-id filter is now applied uniformly at the controller level. - This controller-level per-id filter is necessary regardless of that pre-existing severity: BaseProductController injects AdminShared's unfiltered IProductViewModelService, not Vendor's own filtered one. Once Task 11 subclasses Vendor onto BaseProductController, Vendor loses its service-layer filter entirely, so the controller must enforce both the parent and per-selected-id checks itself going forward. - Admin's original AssociatedProductList/Update/Delete had no access check at all; now gated like Store/Vendor via scope.HasAccess. - Added protected virtual AssociatedProductVendorId hook (default "") so Vendor's original vendor-filtered GetAssociatedProducts(vendorId:) call can be reinstated once hosts subclass BaseProductController (Task 11). - No InvalidAssociatedProductAddPopupResult hook needed: unlike the other AddPopup(POST) actions in this file, all three original hosts share identical invalid-model-state handling here. - Adds characterization/regression tests to BaseProductControllerTests.cs. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 286 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 177 +++++++++++ 2 files changed, 463 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 3f51ccb35e..a806d7e3bd 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1926,4 +1926,290 @@ public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); } + + // --- AssociatedProductList ----------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no + // check at all. + + [TestMethod] + public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", "", "", true)) + .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- AssociatedProductUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductUpdate_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual(5, associatedProduct.DisplayOrder); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); + } + + // --- AssociatedProductDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductDelete_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); + } + + // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupGet_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("store-1")) + .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); + + var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddAssociatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel("store-1"), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("")) + .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); + + var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(""), Times.Once); + } + + // --- AssociatedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel(); + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ + // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership + // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) + // service already filtered each selected product via HasAccessToProduct before reparenting it + // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent + // was never checked anywhere, letting a vendor attach their own products under another vendor's + // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching + // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, + // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is + // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the + // controller must enforce both checks itself. + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() + { + var parent = new Product { Id = "p1" }; + var selected = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() + { + var parent = new Product { Id = "p1" }; + var allowed = new Product { Id = "a1" }; + var denied = new Product { Id = "a2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); + _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + var denied = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var reprepared = new ProductModel.AddAssociatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("store-1")).ReturnsAsync(reprepared); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.AssociatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 28ce74b394..3442e4c44a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1224,4 +1224,181 @@ protected virtual async Task InvalidRecommendedProductAddPopupRes } #endregion + + #region Associated products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task AssociatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into + // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full + // associated-product set, the ones they themselves own. That vendor filter is unconditional in + // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies + // when showHidden is false - moot here since all three hosts pass showHidden: true). + var associatedProducts = await productService.GetAssociatedProducts(productId, + vendorId: AssociatedProductVendorId, showHidden: true); + var associatedProductsModel = associatedProducts + .Select(x => new ProductModel.AssociatedProductModel { + Id = x.Id, + ProductId = productId, + ProductName = x.Name, + DisplayOrder = x.DisplayOrder + }) + .ToList(); + + var gridModel = new DataSourceResult { + Data = associatedProductsModel, + Total = associatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) + { + var associatedProduct = await productService.GetProductById(model.Id); + if (associatedProduct == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and + // Store's CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(associatedProduct)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + associatedProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateAssociatedProduct(associatedProduct); + + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) + { + var product = await productService.GetProductById(model.Id); + if (product == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's + // CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteAssociatedProduct(product); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AssociatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (all open this popup unconditionally + // once the Edit permission is satisfied) - only the mutating actions below tie access to a + // specific product. scope.DefaultStoreId ?? "" matches Store's + // PrepareAssociatedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. + var model = await productViewModelService.PrepareAssociatedProductModel(scope.DefaultStoreId ?? ""); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddAssociatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) + { + var parentProduct = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. + // Vendor's controller had NO check on the parent product at all (see below for the selected-ids + // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected + // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor + // could attach their own products under another vendor's grouped product (cross-vendor storefront + // pollution / data-integrity issue, not a write to someone else's product record). Closed here the + // same way as every other AddPopup(POST) in this task. + if (!await scope.HasAccess(parentProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + // InsertAssociatedProductModel reparents each selected product - it writes + // ParentGroupedProductId directly onto that product's own record - unlike + // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping + // entry that references the selected product's id from the parent's own list; the selected + // product's record is never itself modified there. So here, unlike those simpler regions, + // every selected id must independently pass HasAccess too - the same per-id filtering Store's + // controller-level original already did, and which Vendor's original also had, but only at the + // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: + // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController + // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so + // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter + // entirely. Enforcing the per-id check here at the controller level is what preserves the + // invariant going forward. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (await scope.HasAccess(selected)) validIds.Add(id); + } + + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); + } + + return Content(""); + } + + // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact + // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's + // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it + // uses in those other regions, so no host-specific hook is needed for this action. + Error(ModelState); + model = await productViewModelService.PrepareAssociatedProductModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in + /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into + /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated + /// products that they themselves own. Admin/Store passed no vendorId (both show every associated + /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it + /// once hosts are subclassed onto BaseProductController (Task 11). + protected virtual string AssociatedProductVendorId => ""; + + #endregion } From 73de989a7304493874bb04fd8c016675559c3063 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:04:35 +0200 Subject: [PATCH 024/147] Migrate 'Product pictures' region into BaseProductController (ARCH-001 Phase 1) Closes two Vendor-only IDOR gaps: ProductPicturePopup(POST) and ProductPictureDelete had no ownership check at all, letting any vendor mutate/delete another vendor's product picture by posting its productId/model.Id. Admin never had access checks on any action in this region either (GlobalAdminDataScope.HasAccess is a no-op), now uniformly gated via scope.HasAccess. Flagged, not fixed: ProductPictureAdd has no file-size limit in any of the three original hosts (unlike the recently-hardened attribute upload paths in commit a153496a6), buffering the full upload into memory unconditionally once the extension check passes. Pre-existing across all three hosts; ported as-is per this row's scope. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 219 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 186 +++++++++++++++ 2 files changed, 405 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index a806d7e3bd..ac1ad9f625 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -4,6 +4,7 @@ using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; +using Grand.Domain.Media; using Grand.Infrastructure.Mapper; using Grand.Mapping; using Grand.Web.AdminShared.Controllers; @@ -2212,4 +2213,222 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R Assert.AreSame(reprepared, view.Model); _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); } + + // --- Product pictures --------------------------------------------------------------------------- + // ProductPictureAdd is deliberately not covered here (same rationale as Store's original + // ProductControllerTests): reaching its HasAccess check requires a non-empty IFormFileCollection and + // a prior Pictures-permission check via IPermissionService, disproportionate setup for what is + // otherwise the same one-line HasAccess condition covered everywhere else in this region. + + // --- ProductPictureList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) + .ReturnsAsync(new List { new() { Id = "pic1" } }); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPicturePopup (GET) ------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product picture not exist", content.Content); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { Id = "pic1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) + .ReturnsAsync((model, (Picture)null)); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + } + + // --- ProductPicturePopup (POST) ------------------------------------------------------------------ + + [TestMethod] + public async Task ProductPicturePopupPost_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, + // letting any vendor rename/re-alt-text another vendor's product picture by posting its + // productId/model.Id. + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() + { + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPicturePopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductPictureDelete ------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting + // any vendor delete another vendor's product picture by posting its productId/model.Id. + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 3442e4c44a..cdabe1ed8e 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -22,6 +22,7 @@ using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.StaticFiles; @@ -1401,4 +1402,189 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc protected virtual string AssociatedProductVendorId => ""; #endregion + + #region Product pictures + + [HttpPost] + public async Task ProductPictureAdd( + IFormFileCollection files, + Reference reference, string objectId, + [FromServices] IPictureService pictureService, + [FromServices] MediaSettings mediaSettings) + { + if (!await permissionService.Authorize(PermissionSystemName.Pictures)) + return Json(new { + success = false, + message = "Access denied - picture permissions" + }); + + if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) + return Json(new { + success = false, + message = "Please save form before upload new pictures" + }); + + if (!files.Any()) + return Json(new { + success = false, + message = "No files uploaded" + }); + + var product = await productService.GetProductById(objectId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's inline + // WorkContext.HasAccessToProduct check gating this action on both hosts. Admin's original had no + // check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the same + // way as every other row in this task. Message text kept generic rather than reusing either host's + // wording ("Access denied - staff permissions" / "Access denied - vendor permissions") since this + // is a shared, host-neutral action now. + if (!await scope.HasAccess(product)) + return Json(new { + success = false, + message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") + }); + + // File-upload validation note (ARCH-001 Phase 1 Task 8 row "Product pictures", 2026-08-16): + // extension checking here already goes through FileExtensions.GetAllowedMediaFileTypes, which - + // per commit a153496a6's fix - falls back to a safe image-only allow-list when + // mediaSettings.AllowedFileTypes is empty, so the "empty config = any extension" bypass that + // commit fixed for attribute uploads does not apply here. However, unlike the attribute-upload + // paths that commit hardened (Contact/ShoppingCart/Product's ValidationFileMaximumSize check + // against file.Length before buffering), this action has NO file-size limit at all in any of the + // three original hosts - file.GetDownloadBits() buffers the full upload into memory unconditionally + // for every file that passes the extension check. This is pre-existing, identical behavior across + // all three hosts (not introduced by this consolidation), so it is ported as-is rather than + // "fixed" here per this row's instructions - flagging as a concern: the admin/vendor/store picture + // upload endpoints may be exposed to the same memory-DoS pattern a153496a6 fixed elsewhere, and + // would need an explicit size check (and a decision on what setting should carry the limit, since + // MediaSettings has no equivalent of ValidationFileMaximumSize) before that gap is closed. + var values = new List<(string pictureUrl, string pictureId)>(); + foreach (var file in files) + { + var fileName = Path.GetFileName(file.FileName); + var contentType = file.ContentType; + var fileExtension = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(contentType)) + _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); + + if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) + { + var fileBinary = file.GetDownloadBits(); + //insert picture + var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, + objectId: objectId); + var pictureUrl = await pictureService.GetPictureUrl(picture); + + values.Add((pictureUrl, picture.Id)); + //assign picture to the product + await productViewModelService.InsertProductPicture(product, picture, 0); + } + } + + return Json(new { success = values.Any(), data = values }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPictureList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. Vendor's original signature + // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo + // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for + // Vendor since an unused extra bound parameter changes nothing about the response. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); + var gridModel = new DataSourceResult { + Data = productPicturesModel, + Total = productPicturesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task ProductPicturePopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + return Content("Product not exist"); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); + if (pp == null) + return Content("Product picture not exist"); + + var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); + locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another + // vendor's product picture by posting its productId/model.Id - closed here the same way as the + // other rows in this task. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) + throw new ArgumentException("No product picture found with the specified id"); + + await productViewModelService.UpdateProductPicture(model); + + return Content(""); + } + + Error(ModelState); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product + // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) + // above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductPicture(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From ecf4ca23427c66fa4c149f22bb348c875ffcc81d Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:12:29 +0200 Subject: [PATCH 025/147] Migrate 'Product specification attributes' region into BaseProductController (ARCH-001 Phase 1) Real gap found: Vendor's ProductSpecAttrPopup(POST) and ProductSpecAttrDelete had no access check at all, letting any vendor add/edit/delete specification attributes on another vendor's product by posting its productId/model.Id. Merged onto scope.HasAccess, matching Store's CanAccessProduct and closing the gap on Vendor without changing Admin's superuser behaviour. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 256 +++++++++++++++++- .../Controllers/BaseProductController.cs | 152 +++++++++++ 2 files changed, 407 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index ac1ad9f625..2f35338e9a 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -3,6 +3,7 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Media; using Grand.Infrastructure.Mapper; @@ -55,7 +56,11 @@ private class TestProductController( [TestInitialize] public void Setup() { - var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); + var mapperConfig = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); AutoMapperConfig.Init(mapperConfig); _productServiceMock = new Mock(); @@ -2431,4 +2436,253 @@ public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_Retur Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); } + + // --- Product specification attributes ----------------------------------------------------------- + // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' + // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. + + // --- ProductSpecAttrList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) + .ReturnsAsync(new List { new() { Id = "psa1" } }); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + var json = (JsonResult)result; + var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; + Assert.AreEqual(1, grid.Total); + } + + // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.IsTrue(model.ShowOnProductPage); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() + { + var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.AreEqual("psa1", model.Id); + } + + // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, + // letting any vendor add/edit specification attributes on another vendor's product by posting + // its id. + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(product, psa, model), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() + { + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductSpecAttrDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting + // any vendor delete another vendor's specification attribute mapping by posting its + // productId/model.Id. + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() + { + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index cdabe1ed8e..aaee900dfd 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1587,4 +1587,156 @@ public async Task ProductPictureDelete(ProductModel.ProductPictur } #endregion + + #region Product specification attributes + + //ajax + [AcceptVerbs("GET")] + public async Task GetOptionsByAttributeId(string attributeId, + [FromServices] ISpecificationAttributeService specificationAttributeService) + { + if (string.IsNullOrEmpty(attributeId)) + return Json(""); + + var options = + (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) + .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); + var result = (from o in options + select new { id = o.Id, name = o.Name }).ToList(); + return Json(result); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductSpecAttrList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying + // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess + // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest + // command parameter entirely (unused by the body on any host either way) - kept here for parity + // with Admin/Store. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); + var gridModel = new DataSourceResult { + Data = productrSpecsModel, + Total = productrSpecsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + string productId, string id) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on + // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same + // way as ProductSpecAttrList above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new ProductModel.AddProductSpecificationAttributeModel { + //default specs values + ShowOnProductPage = true + }; + + if (!string.IsNullOrEmpty(id)) + { + var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); + if (specification != null) model = specification.ToModel(); + } + + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + ProductModel.AddProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrPopup(POST) had no check at all, letting any vendor add/edit specification + // attributes on another vendor's product by posting its id - closed here the same way as the + // GET popup above. Vendor's original call also used a two-arg + // UpdateProductSpecificationAttributeModel(psa, model) overload that does not exist on the + // shared IProductViewModelService; the shared three-arg (product, psa, model) overload - + // already used by Admin/Store - is used here instead. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + await productViewModelService.InsertProductSpecificationAttributeModel(model, product); + else + await productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); + + return new JsonResult(""); + } + + Error(ModelState); + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call + /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification + /// attribute regardless of store). + private async Task> PrepareAvailableAttributes( + ISpecificationAttributeService specificationAttributeService) + { + var availableSpecificationAttributes = new List(); + foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) + availableSpecificationAttributes.Add(new SelectListItem { + Text = sa.Name, + Value = sa.Id + }); + return availableSpecificationAttributes; + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's + // specification attribute mapping by posting its productId/model.Id - closed here the same + // way as ProductSpecAttrPopup(POST) above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + throw new ArgumentException("No specification attribute found with the specified id"); + + await productViewModelService.DeleteProductSpecificationAttribute(product, psa); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From 8883e04c2613222782aea1465524549f525dd98e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:20:18 +0200 Subject: [PATCH 026/147] Migrate 'Purchased with order' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 75 ++++++++++++++++++- .../Controllers/BaseProductController.cs | 51 +++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 2f35338e9a..c5ad5fb6b9 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -6,12 +6,14 @@ using Grand.Domain; using Grand.Domain.Catalog; using Grand.Domain.Media; +using Grand.Domain.Permissions; using Grand.Infrastructure.Mapper; using Grand.Mapping; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -52,6 +54,7 @@ private class TestProductController( private Mock _productViewModelServiceMock; private Mock _translationServiceMock; private Mock> _scopeMock; + private Mock _permissionServiceMock; [TestInitialize] public void Setup() @@ -72,6 +75,9 @@ public void Setup() _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _permissionServiceMock = new Mock(); + _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); + var languageServiceMock = new Mock(); languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); @@ -84,7 +90,7 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + _permissionServiceMock.Object, new Mock().Object, _scopeMock.Object); @@ -2685,4 +2691,71 @@ public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_ Assert.IsInstanceOfType(result); _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); } + + // --- Purchased with order ------------------------------------------------------------------------ + // Covers Admin and Store only - Vendor cannot bind to this signature (see the region comment in + // BaseProductController.cs: Vendor has its own, structurally different IOrderViewModelService and + // OrderListModel types). + + [TestMethod] + public async Task PurchasedWithOrders_PermissionDenied_ReturnsEmptyGrid_DoesNotLoadProduct() + { + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); + var orderViewModelServiceMock = new Mock(); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + Assert.IsNull(gridModel.Data); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PurchasedWithOrders_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var orderViewModelServiceMock = new Mock(); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + orderViewModelServiceMock.Verify( + s => s.PrepareOrderModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task PurchasedWithOrders_ScopeGrantsAccess_UsesDefaultStoreIdAndReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + // DefaultStoreId stands in for Store's original model.StoreId = StaffStoreId (and for Admin's + // original, which left StoreId unset/null - GlobalAdminDataScope.DefaultStoreId is null). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var orders = new List { new() { Id = "order1" } }; + var orderViewModelServiceMock = new Mock(); + orderViewModelServiceMock + .Setup(s => s.PrepareOrderModel( + It.Is(m => m.ProductId == "p1" && m.StoreId == "store-1"), 1, 10)) + .ReturnsAsync((orders, orders.Count)); + + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1", + orderViewModelServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index aaee900dfd..ca32bec594 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1739,4 +1739,55 @@ public async Task ProductSpecAttrDelete(ProductSpecificationAttri } #endregion + + #region Purchased with order + + // Type note: this method covers Admin and Store only. Both already share + // Grand.Web.AdminShared's IOrderViewModelService and Models.Orders.OrderListModel (see the usings + // at the top of this file). Vendor defines its own, structurally different + // Grand.Web.Vendor.Interfaces.IOrderViewModelService and Grand.Web.Vendor.Models.Orders.OrderListModel + // (no StoreId property at all - Vendor's PrepareOrderModel scopes by + // _contextAccessor.WorkContext.CurrentVendor.Id internally, not via any model field), so it cannot + // bind to this signature. That vendor-id scoping lives entirely inside Vendor's own + // OrderViewModelService, outside anything IAdminDataScope expresses - flagging as a concern + // per the task brief rather than inventing a shared model/service pair. Left virtual so a future + // Vendor subclass can still override this action with its own types when Vendor is wired onto this + // base controller. + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public virtual async Task PurchasedWithOrders(DataSourceRequest command, string productId, + [FromServices] IOrderViewModelService orderViewModelService) + { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Json(new DataSourceResult { + Data = null, + Total = 0 + }); + + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Admin's original had + // no check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the + // same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new OrderListModel { + ProductId = productId, + // DefaultStoreId is the staff member's store for Store (matches its original + // model.StoreId = StaffStoreId) and null for Admin (matches its original, which never set + // StoreId at all). + StoreId = scope.DefaultStoreId + }; + + var (orderModels, totalCount) = + await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = orderModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + #endregion } From 324131c25d376ccb68c586f9732dbf7ff3db4e69 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:25:20 +0200 Subject: [PATCH 027/147] Fix: PurchasedWithOrders cannot be virtual (Vendor override impossible under C# rules) Vendor's future subclass needs a different IOrderViewModelService/OrderListModel type entirely, which C#'s 'override' cannot express (parameter types must match exactly). Removed the misleading 'virtual' modifier and corrected the comment to describe the actual mechanism: Vendor's subclass will shadow this action with 'new' and its own types, not override it. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductController.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index ca32bec594..4208f710c8 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1750,12 +1750,16 @@ public async Task ProductSpecAttrDelete(ProductSpecificationAttri // _contextAccessor.WorkContext.CurrentVendor.Id internally, not via any model field), so it cannot // bind to this signature. That vendor-id scoping lives entirely inside Vendor's own // OrderViewModelService, outside anything IAdminDataScope expresses - flagging as a concern - // per the task brief rather than inventing a shared model/service pair. Left virtual so a future - // Vendor subclass can still override this action with its own types when Vendor is wired onto this - // base controller. + // per the task brief rather than inventing a shared model/service pair. Not virtual: C#'s override + // rules require an exact parameter-type match, so a Vendor override using + // Grand.Web.Vendor.Interfaces.IOrderViewModelService/Models.Orders.OrderListModel could never compile + // as an override of this signature anyway. When Vendor is wired onto this base controller (Task 11), + // its subclass will declare its own PurchasedWithOrders action with `new` to shadow this one (a + // standard, valid ASP.NET Core MVC pattern for a derived controller needing an incompatible signature + // under the same action name), not override it. [PermissionAuthorizeAction(PermissionActionName.Preview)] [HttpPost] - public virtual async Task PurchasedWithOrders(DataSourceRequest command, string productId, + public async Task PurchasedWithOrders(DataSourceRequest command, string productId, [FromServices] IOrderViewModelService orderViewModelService) { if (!await permissionService.Authorize(StandardPermission.ManageOrders)) From 4591d1dee8d3a996eefd378a33c41f578e4cec8b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:30:12 +0200 Subject: [PATCH 028/147] Migrate 'Reviews' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 77 +++++++++++++++++++ .../Controllers/BaseProductController.cs | 43 +++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index c5ad5fb6b9..803ef43d33 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2758,4 +2758,81 @@ public async Task PurchasedWithOrders_ScopeGrantsAccess_UsesDefaultStoreIdAndRet Assert.IsNotNull(gridModel); Assert.AreEqual(1, gridModel.Total); } + + // --- Reviews --------------------------------------------------------------------------------- + + [TestMethod] + public async Task Reviews_ScopeDeniesAccess_ReturnsErrorJson_DoesNotLoadReviews() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var productReviewServiceMock = new Mock(); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Reviews_ScopeGrantsAccess_WithDefaultStoreId_FiltersReviewsByStore() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + // DefaultStoreId stands in for Store's original storeId argument (the staff member's + // StaffStoreId, used to filter reviews to that store). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var review = new ProductReview { Id = "r1", ProductId = "p1" }; + var reviews = new PagedList(new List { review }, 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "store-1", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + _productViewModelServiceMock.Verify( + s => s.PrepareProductReviewModel(It.IsAny(), review, false, true), Times.Once); + } + + [TestMethod] + public async Task Reviews_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyStoreId() + { + // Matches both Admin's and Vendor's originals, which both passed "" literally (Vendor scopes by + // VendorId via the HasAccess check above, not by store - VendorProductDataScope.DefaultStoreId + // is null). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var reviews = new PagedList(new List(), 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); + + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 4208f710c8..54a723841b 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1794,4 +1794,47 @@ public async Task PurchasedWithOrders(DataSourceRequest command, } #endregion + + #region Reviews + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task Reviews(DataSourceRequest command, string productId, + [FromServices] IProductReviewService productReviewService) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // DefaultStoreId is the staff member's store for Store (matches its original storeId argument, + // which filtered reviews to the staff member's store) and null - normalized to "" here, matching + // GetAllProductReviews's expected "no filter" value - for both Admin and Vendor (matches their + // originals, which both passed "" literally; Vendor scopes by VendorId via the HasAccess check + // above, not by store, since VendorProductDataScope.DefaultStoreId is null). + var storeId = scope.DefaultStoreId ?? ""; + + var productReviews = await productReviewService.GetAllProductReviews("", null, + null, null, "", storeId, productId); + + var items = new List(); + foreach (var item in productReviews.PagedForCommand(command)) + { + var m = new ProductReviewModel(); + await productViewModelService.PrepareProductReviewModel(m, item, false, true); + items.Add(m); + } + + var gridModel = new DataSourceResult { + Data = items, + Total = productReviews.Count + }; + + return Json(gridModel); + } + + #endregion } From 061181c63fef1ff1dc6508ad0ea531017f8dec7c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:45:27 +0200 Subject: [PATCH 029/147] Migrate 'Export / Import' region into BaseProductController (ARCH-001 Phase 1) - ExportExcelAll: identical in Admin/Vendor; relies on the host-specific IProductViewModelService.PrepareProducts implementation for scoping (Vendor's always filters by CurrentVendor.Id internally). - ExportExcelSelected: applies scope.HasAccess per selected id unconditionally, matching Vendor's original explicit re-check and closing the same gap on Admin (which had none) for caller-supplied ids. - ImportExcel: ported from Admin only, non-virtual. Vendor never grants the Products permission's Import action, so PermissionAuthorizeAction already gates it out for that host; no Store region existed for this at all. - Flagged (not fixed, out of scope): ImportExcel has no file-extension allowlist or size cap before reading the stream into memory - same shape of gap commit a153496a6 fixed for attribute uploads, left untouched here as pre-existing behavior being ported verbatim. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 162 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 98 +++++++++++ 2 files changed, 260 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 803ef43d33..d0a0637bcd 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -1,7 +1,9 @@ +using Grand.Business.Core.Dto; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain; using Grand.Domain.Catalog; @@ -17,7 +19,9 @@ using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -95,6 +99,22 @@ public void Setup() _scopeMock.Object); var httpContext = new DefaultHttpContext(); + // Needed for actions whose catch (Exception) block calls Error(exc), which logs via + // HttpContext.RequestServices.GetRequiredService() (see Export / Import below). + // Once RequestServices is non-null, ControllerBase.Url's own + // HttpContext.RequestServices.GetRequiredService() call (used by every + // RedirectToAction(action, controller) - e.g. GoToSku above) stops being short-circuited by the + // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve + // too or those pre-existing tests start throwing. + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) + .Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); } @@ -2835,4 +2855,146 @@ public async Task Reviews_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyStoreI productReviewServiceMock.Verify( s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue), Times.Once); } + + // --- Export / Import --------------------------------------------------------------------------- + + [TestMethod] + public async Task ExportExcelAll_Success_ReturnsXlsxFile() + { + var model = new ProductListModel(); + var products = new List { new() { Id = "p1" } }; + // No scope filtering here: productViewModelService.PrepareProducts is host-specific and already + // returns only the caller's products (Vendor's implementation constrains by CurrentVendor.Id + // internally) - the controller trusts it, same as ProductList trusts PrepareProductsModel. + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync(products); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(products)).ReturnsAsync([1, 2, 3]); + + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + Assert.AreEqual("text/xls", file.ContentType); + Assert.AreEqual("products.xlsx", file.FileDownloadName); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, file.FileContents); + } + + [TestMethod] + public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() + { + var model = new ProductListModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>())) + .ThrowsAsync(new Exception("boom")); + + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ExportExcelSelected_NullSelectedIds_ExportsEmptyList() + { + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()))) + .ReturnsAsync([9]); + + var result = await _controller.ExportExcelSelected(null, exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), true), Times.Never); + } + + [TestMethod] + public async Task ExportExcelSelected_FiltersOutProductsScopeDenies() + { + // Mirrors Vendor's original explicit HasAccessToProduct re-check on selectedIds (caller-supplied, + // not derived from a scoped search, unlike ExportExcelAll) - applied unconditionally here so + // Admin (where the original had no check at all) gets the same protection. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync([owned, foreign]); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + var exportManagerMock = new Mock>(); + exportManagerMock + .Setup(e => e.Export(It.Is>(p => p.Single() == owned))) + .ReturnsAsync([7]); + + var result = await _controller.ExportExcelSelected("owned,foreign", exportManagerMock.Object); + + var file = result as FileContentResult; + Assert.IsNotNull(file); + exportManagerMock.Verify(e => e.Export(It.Is>(p => p.Single() == owned)), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_EmptyFile_DoesNotImport_RedirectsToListWithError() + { + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(0); + var importManagerMock = new Mock>(); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.Common.UploadFile"), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_NullFile_DoesNotImport_RedirectsToListWithError() + { + var importManagerMock = new Mock>(); + + var result = await _controller.ImportExcel(null, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ImportExcel_ValidFile_ImportsAndRedirectsToListWithSuccess() + { + using var stream = new MemoryStream([1, 2, 3]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(3); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).Returns(Task.CompletedTask); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(stream), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Imported"), Times.Once); + } + + [TestMethod] + public async Task ImportExcel_ImportThrows_RedirectsToListWithError() + { + using var stream = new MemoryStream([1]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(1); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).ThrowsAsync(new Exception("bad file")); + + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 54a723841b..99da518925 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1837,4 +1837,102 @@ public async Task Reviews(DataSourceRequest command, string produ } #endregion + + #region Export / Import + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task ExportExcelAll(ProductListModel model, + [FromServices] IExportManager exportManager) + { + // No explicit scope filter needed here: productViewModelService is host-specific (Admin's + // implementation returns all products matching the search model; Vendor's PrepareProducts always + // constrains the SearchProducts call to WorkContext.CurrentVendor.Id regardless of what's in + // model), so scoping is already enforced inside the polymorphic call, same as ProductList above. + var products = await productViewModelService.PrepareProducts(model); + try + { + var bytes = await exportManager.Export(products); + return File(bytes, "text/xls", "products.xlsx"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Export)] + [HttpPost] + public async Task ExportExcelSelected(string selectedIds, + [FromServices] IExportManager exportManager) + { + var products = new List(); + if (selectedIds != null) + { + var ids = selectedIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x) + .ToArray(); + products.AddRange(await productService.GetProductsByIds(ids, true)); + } + + // Unlike ExportExcelAll, selectedIds is caller-supplied and not derived from a scoped search - + // Vendor's original explicitly re-checked HasAccessToProduct per id for exactly this reason + // (a vendor could otherwise pass another vendor's product id and export it). Admin's original had + // no check at all (GlobalAdminDataScope.HasAccess is a no-op there), so applying the filter + // unconditionally closes that gap the same way as every other row in this task, without changing + // Admin's or Store's observable behavior. + var scoped = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + scoped.Add(product); + + var bytes = await exportManager.Export(scoped); + return File(bytes, "text/xls", "products.xlsx"); + } + + // Not virtual: Vendor's original ProductController has no ImportExcel action at all, and Vendor is + // never granted the "Products" permission's Import action (grep across src/Web/Grand.Web.Vendor found + // no PermissionActionName.Import usage anywhere) - vendors are deliberately not allowed to bulk-import + // products. [PermissionAuthorizeAction(PermissionActionName.Import)] below already 403s for any host + // whose role has no Import grant for Products, so this is safe to expose unconditionally on the base + // class; Vendor's (future) subclass simply never routes a view to it, same as any other + // permission-gated action already in this file. + // + // Concern (flagged, not fixed - out of scope for this row): ImportExcel only checks + // `importexcelfile.Length > 0` before handing the raw stream to IImportManager.Import. + // There is no file-extension allowlist and no upper bound on Length before the stream is read into + // memory by the importer. Commit a153496a6 hardened exactly this shape of gap (memory DoS + extension + // bypass) for attribute file uploads; this action has the same shape and was not touched by that fix. + // This is pre-existing behavior being ported verbatim, not something introduced by this migration - + // worth a follow-up ticket, not a silent fix here. + [PermissionAuthorizeAction(PermissionActionName.Import)] + [HttpPost] + public async Task ImportExcel(IFormFile importexcelfile, + [FromServices] IImportManager importManager) + { + try + { + if (importexcelfile is { Length: > 0 }) + { + await importManager.Import(importexcelfile.OpenReadStream()); + } + else + { + Error(translationService.GetResource("Admin.Common.UploadFile")); + return RedirectToAction("List"); + } + + Success(translationService.GetResource("Admin.Catalog.Products.Imported")); + return RedirectToAction("List"); + } + catch (Exception exc) + { + Error(exc); + return RedirectToAction("List"); + } + } + + #endregion } From b8ea13529ada9613ec52512b4cac61c1c35417b4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 21:58:56 +0200 Subject: [PATCH 030/147] Migrate 'Bulk editing' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 158 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 109 ++++++++++++ 2 files changed, 267 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index d0a0637bcd..84af9fced9 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2997,4 +2997,162 @@ public async Task ImportExcel_ImportThrows_RedirectsToListWithError() Assert.IsNotNull(redirect); Assert.AreEqual("List", redirect.ActionName); } + + // --- Bulk editing -------------------------------------------------------------------------------- + + [TestMethod] + public async Task BulkEdit_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel("store1")) + .ReturnsAsync(new BulkEditListModel()); + + var result = await _controller.BulkEdit(); + + Assert.IsInstanceOfType(result, typeof(ViewResult)); + _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel("store1"), Times.Once); + } + + [TestMethod] + public async Task BulkEdit_NullDefaultStoreId_PassesEmptyString() + { + // Admin/Vendor: scope.DefaultStoreId is null (Admin is global; Vendor is not store-scoped) - + // matches Admin's original parameterless call and Vendor's own service's parameterless method. + _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel("")) + .ReturnsAsync(new BulkEditListModel()); + + await _controller.BulkEdit(); + + _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(""), Times.Once); + } + + [TestMethod] + public async Task BulkEditSelect_StoreScoped_SetsSearchStoreIdFromScope() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new BulkEditListModel(); + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task BulkEditSelect_NotStoreScoped_LeavesSearchStoreIdUntouched() + { + var model = new BulkEditListModel { SearchStoreId = "preset" }; + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.AreEqual("preset", model.SearchStoreId); + } + + [TestMethod] + public async Task BulkEditUpdate_NullProducts_DoesNotCallService() + { + var result = await _controller.BulkEditUpdate(null); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditUpdate_FiltersOutProductsScopeDenies() + { + // Regression guard: Admin's original had no ownership check at all on this bulk-mutate endpoint + // (any client-supplied id list was updated unconditionally). Store's original filtered via + // FilterValidProductsForStore/CanAccessProduct; Vendor's original filtered via + // HasAccessToProduct inside its own service. Routing through scope.HasAccess here reproduces + // that per-item gate uniformly. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + var ownedModel = new BulkEditProductModel { Id = "owned" }; + var foreignModel = new BulkEditProductModel { Id = "foreign" }; + + await _controller.BulkEditUpdate(new List { ownedModel, foreignModel }); + + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); + } + + [TestMethod] + public async Task BulkEditUpdate_AllProductsScopeDenies_DoesNotCallUpdateBulkEdit() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditUpdate(new List { new() { Id = "foreign" } }); + + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditDelete_NullProducts_DoesNotCallService() + { + var result = await _controller.BulkEditDelete(null); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditDelete_FiltersOutProductsScopeDenies() + { + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditDelete(new List { + new() { Id = "owned" }, new() { Id = "foreign" } + }); + + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); + } + + [TestMethod] + public async Task BulkEditDelete_AllProductsScopeDenies_DoesNotCallDeleteBulkEdit() + { + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, true)) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + + await _controller.BulkEditDelete(new List { new() { Id = "foreign" } }); + + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); + } + + [TestMethod] + public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCall() + { + var result = await _controller.BulkEditUpdate(new List { new() { Id = "" } }); + + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 99da518925..0b7d492bea 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1935,4 +1935,113 @@ public async Task ImportExcel(IFormFile importexcelfile, } #endregion + + #region Bulk editing + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task BulkEdit() + { + // scope.DefaultStoreId already encodes the per-host default: null for Admin/Vendor (Admin's + // original called PrepareBulkEditListModel() with no storeId; Vendor's own separate service + // (Grand.Web.Vendor.Interfaces.IProductViewModelService.PrepareBulkEditListModel) takes no + // storeId parameter at all - not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareBulkEditListModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + // Known gap, not fixed here (out of this region's file scope): Vendor's original + // PrepareBulkEditProductModel additionally passed vendorId: CurrentVendor.Id into + // productService.SearchProducts, so the grid only ever listed the vendor's own products. The + // shared IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter + // (unlike PrepareProductModel(AddProductModel), which supports SearchVendorId - see + // AssociatedProductVendorId above) - BulkEditListModel carries no SearchVendorId field either. + // Closing this requires a ProductViewModelService/BulkEditListModel change, which this task's + // per-row scope (BaseProductController.cs + tests only) does not permit. Not a security gap by + // itself (a wider listing, not a mutation), but it does mean a vendor could currently see other + // vendors' products in this grid once Vendor is subclassed onto this controller (Task 11) unless + // that follow-up also adds vendor filtering here. The mutate endpoints below (BulkEditUpdate/ + // BulkEditDelete) are scope-checked per item regardless and never leak another party's product. + var (bulkEditProductModels, totalCount) = + await productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bulkEditProductModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BulkEditUpdate(IEnumerable products) + { + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.UpdateBulkEdit(validProducts); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task BulkEditDelete(IEnumerable products) + { + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.DeleteBulkEdit(validProducts); + + return new JsonResult(""); + } + + /// + /// Filters a caller-supplied bulk-edit product list (BulkEditUpdate/BulkEditDelete) down to the ones + /// the current user may mutate. Same shape as the DeleteSelected gap above: this is a caller-supplied + /// list of ids being mutated in one request, and each id must be scope-checked individually before + /// mutation, not just implicitly trusted because it appeared in a grid the user was shown. + /// + /// Admin's original had NO check at all here - both BulkEditUpdate and BulkEditDelete accepted a + /// client-supplied list of ids and mutated/deleted every one of them unconditionally, a real unscoped + /// bulk-mutate/bulk-delete IDOR (any authenticated admin user hitting these actions directly - not + /// through the grid - could update or delete any product in the system by id). + /// + /// Store's original had this exact check (FilterValidProductsForStore, via CanAccessProduct / + /// AccessToEntityByStore) - HasAccess (strict), matching CanAccessProduct's strict rule, not CanView. + /// + /// Vendor's original enforced the equivalent strict check (HasAccessToProduct / VendorId equality) + /// inside its own separate service (Grand.Web.Vendor's ProductViewModelService.UpdateBulkEdit/ + /// DeleteBulkEdit), not in the controller - functionally the same per-item gate, just placed one layer + /// down. Routing it through scope.HasAccess here reproduces that gate at the controller layer, where + /// the shared IProductViewModelService.UpdateBulkEdit/DeleteBulkEdit used by this class does not + /// filter internally (verified: both loop and mutate/delete every product they're given, no ownership + /// check). + /// + /// No-op for Admin (GlobalAdminDataScope.HasAccess is always true, so validProducts == products). + /// Silently drops missing/inaccessible ids rather than throwing (matches Admin's/Vendor's + /// null-then-skip behavior, not Store's original throw-on-missing-id - the majority behavior, and a + /// single bad id in a bulk request shouldn't fail the whole batch). + /// + private async Task> FilterBulkEditProductsByAccess( + IEnumerable products) + { + if (products == null) return []; + + var byId = products + .Where(x => !string.IsNullOrEmpty(x.Id)) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.First()); + if (byId.Count == 0) return []; + + var loadedProducts = await productService.GetProductsByIds(byId.Keys.ToArray(), true); + var validProducts = new List(); + foreach (var product in loadedProducts) + if (await scope.HasAccess(product)) + validProducts.Add(byId[product.Id]); + + return validProducts; + } + + #endregion } From a8def835a395e3fd13997c8ff01238f9195e63e8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 22:05:31 +0200 Subject: [PATCH 031/147] Plan fix: make PrepareBulkEditProductModel vendor-scoping a hard Task 11 prerequisite Task 8 row 15's review found Vendor's original bulk-edit grid was vendor-scoped (vendorId passed into SearchProducts), but AdminShared's PrepareBulkEditProductModel and BulkEditListModel have no equivalent field. BaseProductController.BulkEditSelect now routes to the unfiltered version - harmless today since Vendor isn't subclassed yet, but would silently leak all vendors' products into the grid the moment Task 11 wires Vendor onto BaseProductController. Added an explicit checklist row to Task 10 and a blocking-prerequisite note to Task 11. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-16-arch001-product-consolidation-phase1.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index aae10312a0..ed91f3533a 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -1256,6 +1256,7 @@ Same per-row discipline as Task 8: one method (or tightly-coupled small group, e - [ ] `PrepareRecommendedProductModel(storeId)` — drop `storeId` - [ ] `PrepareAssociatedProductModel(storeId)` — drop `storeId` - [ ] `PrepareBulkEditListModel(storeId)` — drop `storeId` +- [ ] `PrepareBulkEditProductModel` — **hard prerequisite, not optional:** Vendor's original implementation (`src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs`) passes `vendorId: contextAccessor.WorkContext.CurrentVendor.Id` into the underlying `SearchProducts` call, vendor-scoping the bulk-edit grid; AdminShared's version and `BulkEditListModel` have no vendor-id field/parameter at all. `BaseProductController.BulkEditSelect` (Task 8 row 15, already migrated) routes to AdminShared's unfiltered version — this is fine today only because Vendor is not yet subclassed onto `BaseProductController`. **Task 11 must not wire Vendor's `ProductController` onto `BaseProductController` until this method gains vendor-scoped filtering** (e.g. via `scope.ApplyScope` on the underlying query, or an injected filter callback) — doing so first would silently let any vendor see every vendor's products in the bulk-edit grid. Flag this row's completion as blocking Task 11, not merely a nice-to-have cleanup. - [ ] `PrepareTierPriceModel(Product, storeId)` — drop `storeId` - [ ] `PrepareBidMode` - [ ] `PrepareProductAttributeMappingModel` (4 overloads at lines 1365/1379/1392/1526 — AdminShared has one more overload than Vendor per the interface diff; confirm which one and whether Vendor needs it) @@ -1289,6 +1290,8 @@ Expected: no matches (or only local variables inside method bodies that read `sc ## Task 11: Convert the three host `ProductController`s to thin subclasses +**Blocking prerequisite (added after Task 8 row 15's review):** do not subclass Vendor's `ProductController` onto `BaseProductController` until Task 10's `PrepareBulkEditProductModel` row is done and confirmed to preserve vendor-scoped filtering on the bulk-edit grid — see that row's note. Wiring Vendor on first would silently expose every vendor's products in `BulkEditSelect`'s grid to every other vendor. Admin and Store have no equivalent gap and can be subclassed independently of this prerequisite. + **Files:** - Modify (rewrite, shrink): `src/Web/Grand.Web.Admin/Controllers/ProductController.cs` - Modify (rewrite, shrink): `src/Web/Grand.Web.Store/Controllers/ProductController.cs` From 87899dc071a6f077567e8778f6650d935a920a45 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 22:07:04 +0200 Subject: [PATCH 032/147] Strengthen Bulk editing region comment: flag Task 11 blocking prerequisite (ARCH-001 Phase 1) --- .../Controllers/BaseProductController.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 0b7d492bea..5d8f43d2a8 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1955,18 +1955,20 @@ public async Task BulkEditSelect(DataSourceRequest command, BulkE { if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - // Known gap, not fixed here (out of this region's file scope): Vendor's original - // PrepareBulkEditProductModel additionally passed vendorId: CurrentVendor.Id into - // productService.SearchProducts, so the grid only ever listed the vendor's own products. The - // shared IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter - // (unlike PrepareProductModel(AddProductModel), which supports SearchVendorId - see - // AssociatedProductVendorId above) - BulkEditListModel carries no SearchVendorId field either. + // BLOCKING PREREQUISITE for Task 11 (see plan's Task 10 PrepareBulkEditProductModel row and + // Task 11's blocking-prerequisite note, added in commit a8def835a after this gap was flagged and + // verified during review): Vendor's original bulk-edit grid was vendor-scoped - + // PrepareBulkEditProductModel passed vendorId: CurrentVendor.Id into productService.SearchProducts, + // so the grid only ever listed the vendor's own products. AdminShared's version isn't - + // IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter (unlike + // PrepareProductModel(AddProductModel), which supports SearchVendorId - see + // AssociatedProductVendorId above) - and BulkEditListModel carries no SearchVendorId field either. // Closing this requires a ProductViewModelService/BulkEditListModel change, which this task's // per-row scope (BaseProductController.cs + tests only) does not permit. Not a security gap by - // itself (a wider listing, not a mutation), but it does mean a vendor could currently see other - // vendors' products in this grid once Vendor is subclassed onto this controller (Task 11) unless - // that follow-up also adds vendor filtering here. The mutate endpoints below (BulkEditUpdate/ - // BulkEditDelete) are scope-checked per item regardless and never leak another party's product. + // itself (a wider listing, not a mutation) - the mutate endpoints below (BulkEditUpdate/ + // BulkEditDelete) are scope-checked per item regardless and never leak another party's product - + // but Task 11 must NOT subclass Vendor onto this base controller until Task 10 adds vendor-scoped + // filtering here, or a vendor would see every vendor's products in this grid. var (bulkEditProductModels, totalCount) = await productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { From 5b5d73bd758b362417e707aa652b10bb83236cc3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 22:12:53 +0200 Subject: [PATCH 033/147] Migrate 'Product currency price' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 154 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 135 +++++++++++++++ 2 files changed, 289 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 84af9fced9..ad82e2ce65 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -3155,4 +3155,158 @@ public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCa _productViewModelServiceMock.Verify( s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } + + // --- ProductPriceList ------------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it + // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on + // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List + // (Insert/Update/Delete had no check at all). + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPriceInsert ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( + pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); + } + + // --- ProductPriceUpdate ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); + } + + // --- ProductPriceDelete ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 5d8f43d2a8..677a017707 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2046,4 +2046,139 @@ private async Task> FilterBulkEditProductsByAccess( } #endregion + + #region Product currency price + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) check + // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's + // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check + // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had + // no check at all) - both let another party's product prices be updated/deleted by id. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var items = new List(); + foreach (var item in product.ProductPrices) + items.Add(new ProductModel.ProductPriceModel { + Id = item.Id, + CurrencyCode = item.CurrencyCode, + Price = item.Price, + ProductId = product.Id + }); + + var gridModel = new DataSourceResult { + Data = items, + Total = items.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("Currency code exists"); + + if (ModelState.IsValid) + try + { + await productService.InsertProductPrice(new ProductPrice { + ProductId = product.Id, + CurrencyCode = model.CurrencyCode, + Price = model.Price + }); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("You can't use this currency code"); + + if (ModelState.IsValid) + try + { + productPrice!.CurrencyCode = model.CurrencyCode; + productPrice.Price = model.Price; + productPrice.ProductId = model.ProductId; + + await productService.UpdateProductPrice(productPrice); + + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (ModelState.IsValid) + { + productPrice!.ProductId = model.ProductId; + await productService.DeleteProductPrice(productPrice); + + return new JsonResult(""); + } + + // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. + // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since + // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - + // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot + // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From c2b40a458e9469ccb5178a0633587d5df9424496 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 22:20:39 +0200 Subject: [PATCH 034/147] Migrate 'Tier prices' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 259 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 146 ++++++++++ 2 files changed, 405 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index ad82e2ce65..7c49015b2f 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -3309,4 +3309,263 @@ public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); } + + // --- TierPriceList ---------------------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership + // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on + // any product by id. Store's original never checked TierPriceEditPopup(GET) either. + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeGrantsAccess_UsesScopeDefaultStoreId_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var tierPrices = new List { new() { Id = "tp1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product, "store1")).ReturnsAsync(tierPrices); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + [TestMethod] + public async Task TierPriceList_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyString() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product, "")) + .ReturnsAsync(new List()); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareTierPriceModel(product, ""), Times.Once); + } + + // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Get_PreparesModelWithScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + + var result = await _controller.TierPriceCreatePopup("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1"), "store1"), + Times.Once); + } + + // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify( + s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.TierPriceModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); + } + + // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ + // HasAccess added here: Store's original had no ownership check on this GET action at all. + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your product", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Empty tier price", content.Content); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModelWithScopeDefaultStoreId() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1"), "store1"), + Times.Once); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); + } + + // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); + } + + // --- TierPriceDelete ---------------------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 677a017707..02974a425b 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2181,4 +2181,150 @@ public async Task ProductPriceDelete(ProductModel.ProductPriceMod } #endregion + + #region Tier prices + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task TierPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the + // same class of gap as "Product currency price": Vendor's original checked ownership only on List + // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier + // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess + // uniformly on every mutating action below closes that. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // Old storeId-parameter overload (still present pending Task 9/10); scope.DefaultStoreId is null for + // Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store, same as the other rows + // still on this signature. + var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product, scope.DefaultStoreId ?? ""); + var gridModel = new DataSourceResult { + Data = tierPricesModel, + Total = tierPricesModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceCreatePopup(string productId) + { + var model = new ProductModel.TierPriceModel { + ProductId = productId + }; + await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Vendor's original never even loaded the product here - it inserted straight off + // model.ProductId with no ownership check at all. See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = model.ToEntity(dateTimeService); + await productService.InsertTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceEditPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Store's original never checked ownership on this GET action (only on the + // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, + // via this popup) the tier-price edit view for a product outside their store. Vendor's original did + // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. + if (!await scope.HasAccess(product)) + return Content("This is not your product"); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice == null) + return Content("Empty tier price"); + + var model = tierPrice.ToModel(dateTimeService); + model.ProductId = productId; + await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(productId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on this POST at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + return Content("Empty tier price"); + + tierPrice = model.ToEntity(tierPrice, dateTimeService); + await productService.UpdateTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //stores + await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on Delete at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + throw new ArgumentException("No tier price found with the specified id"); + + await productService.DeleteTierPrice(tierPrice, product.Id); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From 5bac264607d17955abbeb4275ccf1adae117d34c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 16 Aug 2026 22:28:23 +0200 Subject: [PATCH 035/147] Migrate 'Product attributes' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 373 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 150 +++++++ 2 files changed, 523 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 7c49015b2f..d0f56b757c 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -3568,4 +3568,377 @@ public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); } + + // --- ProductAttributeMappingList ------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Store's original checked ownership + // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on + // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on + // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or + // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on + // any product, not just their own, by posting a known productId. + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attributes = new List { new() { Id = "pam1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) + .ReturnsAsync(attributes); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); + } + + // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- + // HasAccess added here: Vendor's original had no ownership check on this POST at all. + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeMappingModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() + { + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + // --- ProductAttributeMappingDelete ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeMappingDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- + // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a + // store/vendor user could update an attribute mapping's validation rules on any product by posting a + // known productId/model.Id. + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 02974a425b..a8910390a3 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2327,4 +2327,154 @@ public async Task TierPriceDelete(ProductModel.TierPriceDeleteMod } #endregion + + #region Product attributes + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally + // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither + // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at + // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on + // any product (not just their own/in-scope one) by posting a known productId. Vendor's + // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); + var gridModel = new DataSourceResult { + Data = attributesModel, + Total = attributesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(productAttributeMappingId)) + { + var model = await productViewModelService.PrepareProductAttributeMappingModel(product); + return View(model); + } + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, + productAttributeMapping); + return View(editModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the + // List-action comment. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(model.Id)) + await productViewModelService.InsertProductAttributeMappingModel(model); + else + await productViewModelService.UpdateProductAttributeMappingModel(model); + + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValidationRulesPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though + // this action returns a View (not a grid), which would have rendered raw JSON as page content on + // denial - using Content(...) instead, consistent with the other popup GET action in this region. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValidationRulesPopup( + ProductModel.ProductAttributeMappingModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); + if (productAttributeMapping == null) + throw new ArgumentException("No attribute value found with the specified id"); + + // HasAccess here too: none of the three original hosts checked ownership on this POST at all - + // a store/vendor user could update an attribute mapping's validation rules (min/max length, + // allowed file extensions, default value) on any product by posting a known productId/model.Id. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + #endregion } From 9daa23856c3c13cd625338dc961c4f1019e14b65 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:09:36 +0200 Subject: [PATCH 036/147] Migrate 'Product attributes. Condition' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 138 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 56 +++++++ 2 files changed, 194 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index d0f56b757c..534e478cdf 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -3941,4 +3941,142 @@ public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_In s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), It.IsAny()), Times.Never); } + + // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- + // HasAccess added uniformly. Admin's original had no ownership check on either action of this + // region at all. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) + .ReturnsAsync(new ProductAttributeConditionModel()); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), + Times.Once); + } + + // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on + // any product by posting a known productId/productAttributeMappingId. Admin's original had no + // check on either action. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeConditionModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), + Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index a8910390a3..ae5bf1cf67 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2477,4 +2477,60 @@ public async Task ProductAttributeValidationRulesPopup( } #endregion + + #region Product attributes. Condition + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original + // had no ownership check on either action of this region. See the "Product attributes" region above + // for the same pattern. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + //No attribute value found with the specified id + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeConditionModel(product, + productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and + // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. + // ProductAttributeConditionModel carries no [Required]/validation attributes, so ModelState is + // always valid in practice - dropping the check matches the majority (Admin/Store) with no + // observable behavior change. + // + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any + // product by posting a known productId/productAttributeMappingId. Admin's original had no check on + // either action. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); + return Content(""); + } + + #endregion } From bb22f3831255708a58df1213ff720c49429bf982 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:16:46 +0200 Subject: [PATCH 037/147] Fix inaccurate comment on dropped Vendor ModelState.IsValid check (ARCH-001 Phase 1) --- .../Controllers/BaseProductController.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index ae5bf1cf67..3b3041c6b7 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2517,9 +2517,13 @@ public async Task ProductAttributeConditionPopup(ProductAttribute // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. - // ProductAttributeConditionModel carries no [Required]/validation attributes, so ModelState is - // always valid in practice - dropping the check matches the majority (Admin/Store) with no - // observable behavior change. + // That check was not inert: Vendor's ProductAttributeConditionModel implements the + // IProductValidVendor marker interface, which the global ValidationFilter resolves to + // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - + // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess + // (product) call below now performs the equivalent check directly against the loaded entity, + // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a + // request field. // // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any From f7f64c37f0cfac98ba0c28c34f65cf13e4cd29a1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:25:25 +0200 Subject: [PATCH 038/147] Migrate 'Product attribute values' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 592 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 270 ++++++++ 2 files changed, 862 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 534e478cdf..d849739ebb 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -4079,4 +4079,596 @@ public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates( _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), Times.Once); } + + // --- EditAttributeValues (GET) ------------------------------------------------------------------ + // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. + + [TestMethod] + public async Task EditAttributeValues_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; + var product = new Product { Id = "p1", Name = "Product 1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) + .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.ProductAttributeValueListModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + Assert.AreEqual("Product 1", model.ProductName); + Assert.AreEqual("Color", model.ProductAttributeName); + Assert.AreEqual("pam1", model.ProductAttributeMappingId); + } + + // --- ProductAttributeValueList (POST) ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var values = new List { new() { Id = "pav1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) + .ReturnsAsync(values); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); + } + + // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- + // HasAccess added explicitly: Admin's original had no ownership check on this action at all (neither + // explicit nor via validator). Vendor's original relied solely on IProductValidVendor + ModelState - + // no explicit action-level check. + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) + .ReturnsAsync(model); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did); Admin's original had no check on either action. + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueEditPopup("missing", model)); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueDelete ----------------------------------------------------------------- + // HasAccess added here: this action takes only simple string parameters, so no model-level validator + // ever runs for it. Admin's original had no ownership check at all. + + [TestMethod] + public async Task ProductAttributeValueDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Get_PassesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel("store1")) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); + + var result = await _controller.AssociateProductToAttributeValuePopup(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel("store1"), + Times.Once); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Get_NullDefaultStoreId_PassesEmptyString() + { + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel("")) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); + + var result = await _controller.AssociateProductToAttributeValuePopup(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(""), Times.Once); + } + + // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() + { + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { SearchStoreId = "preset" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("preset", model.SearchStoreId); + } + + // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- + // HasAccess added here: Admin's original had no ownership check on the associated product at all. + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "missing" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Cannot load a product", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + Assert.IsInstanceOfType(result); + var content = result as ContentResult; + Assert.AreEqual("", content.Content); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 3b3041c6b7..571d782165 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2537,4 +2537,274 @@ public async Task ProductAttributeConditionPopup(ProductAttribute } #endregion + + #region Product attribute values + + //list + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task EditAttributeValues(string productAttributeMappingId, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's + // original had no ownership check on this action at all. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var productAttribute = + await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); + var model = new ProductModel.ProductAttributeValueListModel { + ProductName = product.Name, + ProductId = product.Id, + ProductAttributeName = productAttribute.Name, + ProductAttributeMappingId = productAttributeMappingId + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, + DataSourceRequest command) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var values = + await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); + var gridModel = new DataSourceResult { + Data = values, + Total = values.Count + }; + return Json(gridModel); + } + + //create + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, + string productId) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View (not a grid), which would have rendered raw JSON as page + // content on denial - using Content(...) instead, consistent with the sibling GET popup actions + // in this region and elsewhere in this file. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var model = + await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); + //locales + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added explicitly rather than relying on validation-layer side effects: Admin's and + // Store's shared ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) only enforces + // ownership when StaffStoreId is set - i.e. it's active for Store, a no-op for Admin. Vendor's + // model implements IProductValidVendor, so the global ValidationFilter resolves + // IValidator (ProductValidVendor) and adds a ModelState error when + // product.VendorId doesn't match the current vendor - a real check, not a no-op - but + // ValidationFilter never short-circuits a non-JSON POST (see ValidationFilter.OnActionExecutionAsync), + // so that protection only actually held because Vendor's original action gated the insert behind + // `if (ModelState.IsValid)`. Admin's original had neither an explicit check nor that validator + // wired up, so this was a real, unguarded IDOR: any caller reaching this shared action without the + // Vendor marker-interface model could insert an attribute value onto a product they don't own. + // scope.HasAccess makes the check explicit and uniform across all three hosts instead of leaning on + // ModelState side effects that only covered one of them. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + //No product attribute found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.InsertProductAttributeValueModel(model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueEditPopup(string id, string productId, + string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (pa == null) + return RedirectToAction("List", "Product"); + + var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = pav.GetTranslation(x => x.Name, languageId, false); + }); + //pictures + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueEditPopup(string productId, + ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - + // the same applies here (this action shares the same model type). Vendor's original never checked + // ownership on this POST at all (only its GET sibling did, via CheckAccessToProduct/HasAccessToProduct); + // Admin's original had no check on either action. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) + ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValueModel(pav, model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //delete + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueDelete(string id, string pam, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here: this action takes only simple string parameters (no complex-typed POST + // body), so none of the model-level validators discussed above ever run for it. Admin's original + // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess + // now replaces uniformly. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues + .FirstOrDefault(x => x.Id == id); + if (pav == null) + throw new ArgumentException("No product attribute value found with the specified id"); + + if (ModelState.IsValid) + { + await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + public async Task AssociateProductToAttributeValuePopup() + { + // scope.DefaultStoreId ?? "": matches Store's original (passed StaffStoreId to scope the search to + // the staff member's store); null for Admin/Vendor (no store concept), matching their originals + // (no argument, defaulting to ""). + var model = + await productViewModelService.PrepareAssociateProductToAttributeValueModel(scope.DefaultStoreId ?? ""); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopup( + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); + if (associatedProduct == null) + return Content("Cannot load a product"); + + // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's + // originals checked ownership of the *associated* product (the one about to be referenced as an + // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so + // the associated product is the only entity available to check. + if (!await scope.HasAccess(associatedProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + return Content(""); + } + + #endregion } From 46644817a8ee8a3988c6d2ca08a6f31c7f57ec40 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:30:51 +0200 Subject: [PATCH 039/147] Plan fix: flag Vendor's field-level validators as dead code once Task 12 unifies models Task 8 row 20's review found ProductAttributeValueModelValidator.cs targets Vendor's own model type; once Task 12 repoints Vendor onto AdminShared's shared model, this validator never fires again unless AdminShared's equivalent is registered for the Vendor host too. Ownership is already covered by scope.HasAccess; this is the remaining field-level gap. Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-08-16-arch001-product-consolidation-phase1.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index ed91f3533a..165f4513ca 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -1388,6 +1388,8 @@ Vendor's rewritten controller from Step 3 stays as an uncommitted working-tree c **Interfaces:** - Consumes: `Grand.Web.AdminShared.Interfaces.IProductViewModelService` / `.Services.ProductViewModelService` (Tasks 9-10, now fully reconciled). +**Addendum — Vendor's field-level FluentValidation validators (added after Task 8 row 20's review):** once Vendor's controllers bind to AdminShared's model types instead of its own (this task), Vendor-specific validators registered against the old Vendor model types — e.g. `Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs`, which validates `Grand.Web.Vendor.Models.Catalog.ProductModel.ProductAttributeValueModel`'s Name/Quantity/ColorSquares/ImageSquares fields — become dead code: nothing binds the type they validate anymore. This is a field-level validation gap, not an ownership one (ownership is already covered by `scope.HasAccess`, added in Task 8). Before this task is done, grep `src/Web/Grand.Web.Vendor/Validators/` for every validator targeting a type this task's model-unification retires, and confirm AdminShared's equivalent validator (if one exists) is registered for the Vendor host too — check `AdminShared/Startup/StartupApplication.cs`'s FluentValidation registration for whether it already scans all consuming assemblies or needs an explicit add. + - [ ] **Step 1: Find every remaining reference to the old Vendor-local types** Run: From ed2e8f0259fa258d508572459f8cfdeae0c36888 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:34:04 +0200 Subject: [PATCH 040/147] Correct row-20 narrative: Vendor was the at-risk host, not Admin (ARCH-001 Phase 1) - Fix false claim that Vendor's ProductAttributeValueEditPopup(POST) had no ownership check; it did (inline HasAccessToProduct) - scope.HasAccess is a mechanical substitution there, not a fix. - Fix mislabeled victim host on ProductAttributeValueCreatePopup(POST): Admin has no ownership concept (GlobalAdminDataScope.HasAccess is always true) so was never at risk. The real risk was Vendor silently losing its IProductValidVendor-driven guard when the model moved to the shared AdminShared type. - Add ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix test covering the actual host/action combination this row's fix protects. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 32 +++++++++++++++-- .../Controllers/BaseProductController.cs | 36 ++++++++++--------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index d849739ebb..9b57a660a0 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -4242,9 +4242,12 @@ public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_Prepare } // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- - // HasAccess added explicitly: Admin's original had no ownership check on this action at all (neither - // explicit nor via validator). Vendor's original relied solely on IProductValidVendor + ModelState - - // no explicit action-level check. + // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is + // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied + // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's + // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level + // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped + // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. [TestMethod] public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() @@ -4274,6 +4277,29 @@ public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_Return Times.Never); } + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() + { + // The host/action combination this row's fix actually protects: Vendor losing its + // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in + // BaseProductController.ProductAttributeValueCreatePopup(POST). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + [TestMethod] public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() { diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 571d782165..98a80fef5a 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2632,19 +2632,21 @@ public async Task ProductAttributeValueCreatePopup(ProductModel.P if (product == null) throw new ArgumentException("No product found with the specified id"); - // HasAccess added explicitly rather than relying on validation-layer side effects: Admin's and - // Store's shared ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) only enforces - // ownership when StaffStoreId is set - i.e. it's active for Store, a no-op for Admin. Vendor's - // model implements IProductValidVendor, so the global ValidationFilter resolves - // IValidator (ProductValidVendor) and adds a ModelState error when - // product.VendorId doesn't match the current vendor - a real check, not a no-op - but - // ValidationFilter never short-circuits a non-JSON POST (see ValidationFilter.OnActionExecutionAsync), - // so that protection only actually held because Vendor's original action gated the insert behind - // `if (ModelState.IsValid)`. Admin's original had neither an explicit check nor that validator - // wired up, so this was a real, unguarded IDOR: any caller reaching this shared action without the - // Vendor marker-interface model could insert an attribute value onto a product they don't own. - // scope.HasAccess makes the check explicit and uniform across all three hosts instead of leaning on - // ModelState side effects that only covered one of them. + // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no + // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at + // risk here despite having neither an explicit check nor a validator. Store's shared + // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever + // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this + // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the + // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a + // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a + // no-op - but ValidationFilter never short-circuits a non-JSON POST (see + // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's + // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the + // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently + // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now + // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning + // on a marker-interface side effect that only one host had. if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); @@ -2706,9 +2708,11 @@ public async Task ProductAttributeValueEditPopup(string productId throw new ArgumentException("No product found with the specified id"); // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - - // the same applies here (this action shares the same model type). Vendor's original never checked - // ownership on this POST at all (only its GET sibling did, via CheckAccessToProduct/HasAccessToProduct); - // Admin's original had no check on either action. + // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), + // Vendor's original for THIS action already had an explicit inline check + // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), + // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check + // on either action of this pair. if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); From bc9727d74e71c1f05f97b534e852df87ff2f5f47 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:45:19 +0200 Subject: [PATCH 041/147] Migrate 'Product attribute combinations' region into BaseProductController (ARCH-001 Phase 1) Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 293 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 167 ++++++++++ 2 files changed, 460 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 9b57a660a0..eab1612161 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -16,6 +16,7 @@ using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; using Grand.Web.Common.Localization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -4697,4 +4698,296 @@ public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_R var content = result as ContentResult; Assert.AreEqual("", content.Content); } + + // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationDelete ------------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); + } + + // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel { Id = "c1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) + .ReturnsAsync(model); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("missing", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List()); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List { "warning" }); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + CollectionAssert.Contains(model.Warnings.ToList(), "warning"); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- GenerateAllAttributeCombinations --------------------------------------------------------------- + + [TestMethod] + public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); + } + + // --- ClearAllAttributeCombinations ----------------------------------------------------------------- + + [TestMethod] + public async Task ClearAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ClearAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() + { + var product = new Product + { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 98a80fef5a..8079fc4280 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2811,4 +2811,171 @@ public async Task AssociateProductToAttributeValuePopup( } #endregion + + #region Product attribute combinations + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's + // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key + // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper + // that returned a plain hardcoded string ("This is not your product" / "Product not exists") + // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource + // key normalizes all three to the convention used throughout this file. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); + var gridModel = new DataSourceResult { + Data = combinationsModel, + Total = combinationsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here, checked before the combination lookup: matches the + // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity + // exists). Vendor's original combined the null-check and the ownership check into a single throw + // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson + // convention - this is a Kendo grid row-delete action - matching how Store's original reported + // denial (though Store checked access after the combination lookup, not before). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); + if (combination == null) + throw new ArgumentException("No product attribute combination found with the specified id"); + + await productAttributeService.DeleteProductAttributeCombination(combination, productId); + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + var pr = await productService.GetProductById(productId); + pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); + pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(pr, false); + } + + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AttributeCombinationPopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View inside a magnificPopup modal (not a grid), which would render + // raw JSON as page content on denial - using Content(...) instead, consistent with the + // EditAttributeValues GET popup correction in region "Product attribute values" above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AttributeCombinationPopup(string productId, + ProductAttributeCombinationModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + //No product found with the specified id + return RedirectToAction("List", "Product"); + + // Content(...) on denial, matching Store's original. Vendor's original folded denial into the + // same redirect used for a missing product; Content(...) surfaces the permissions message instead + // of silently redirecting, matching every other Edit-scoped action in this region. + // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement + // IProductValidVendor and has no registered FluentValidation validator, so there is no + // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); + if (!warnings.Any()) return Content(""); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + model.Warnings = warnings; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax + // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original + // returned Content(resource) on denial - a plain string the client's dataType:'json' parser + // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, + // showing a generic "Error while generating attribute combinations" alert. Vendor's original + // folded denial into the null-product throw (also an unhandled exception). Normalized here to a + // real JSON response, which avoids that parse-exception/generic-alert path - but note the + // .cshtml's ajax success callback for this action ignores the response body entirely (it just + // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a + // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either + // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for + // this controller-only row (flagged for Phase 2 view work). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.GenerateAllAttributeCombinations(product); + + return Json(new { Success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ClearAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.ClearAllAttributeCombinations(product); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = 0; + product.ReservedQuantity = 0; + await inventoryManageService.UpdateStockProduct(product, false); + } + + return Json(new { Success = true }); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion } From 62d77e20a4a6b37bac63b415e60684cc5039597c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 06:56:42 +0200 Subject: [PATCH 042/147] Migrate 'Product Attribute combination - tier prices' region into BaseProductController (ARCH-001 Phase 1) --- .../Controllers/BaseProductControllerTests.cs | 212 ++++++++++++++++++ .../Controllers/BaseProductController.cs | 106 +++++++++ 2 files changed, 318 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index eab1612161..e978022933 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -4990,4 +4990,216 @@ public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelSt Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); } + + // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; + combination.TierPrices.Add(tierPrice); + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 8079fc4280..85c245d5d8 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -2978,4 +2978,110 @@ public async Task ClearAllAttributeCombinations(string productId) } #endregion + + #region Product Attribute combination - tier prices + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, + string productId, string productAttributeCombinationId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check at all on this region (any of its + // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit + // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on + // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global + // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing + // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if + // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here + // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently + // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions + // (no-op for Admin, equivalent-or-stronger for Store/Vendor). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPriceModel = + await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, + productAttributeCombinationId); + var gridModel = new DataSourceResult { + Data = tierPriceModel, + Total = tierPriceModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceInsert(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check + // would have silently disappeared without an explicit scope.HasAccess call. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = + product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceUpdate(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceDelete(string productId, + string productAttributeCombinationId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both + // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + { + var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice != null) + await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, + tierPrice); + } + + return new JsonResult(""); + } + + #endregion } From e55e5ab066988ad7d8b2dd598936efbf5f78893f Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 07:08:49 +0200 Subject: [PATCH 043/147] Migrate 'Reservation' region into BaseProductController (ARCH-001 Phase 1) Merges ListReservations, GenerateCalendar, ClearCalendar, ClearOld, and ProductReservationDelete from the three ProductControllers, gating all five on scope.HasAccess (Admin's originals had no check at all; Store used CanAccessProduct; Vendor used CheckAccessToProduct/HasAccessToProduct). Fixes a real cross-product IDOR present in Store's and Vendor's original ProductReservationDelete: the access check ran against model.ProductId while the delete ran against model.ReservationId, with no verification that the two referred to the same product. A caller with access to any product could pass that product's id to satisfy the access check while supplying the ReservationId of a reservation on a different, unowned product, deleting it. Closed by verifying toDelete.ProductId == product.Id before deleting. GenerateCalendar's denial response is now uniformly {errors: message} on the templated ResourceKeyPrefix (Store's original shape; verified against its view, whose success callback reads data.success/data.errors) - strictly more informative than Vendor's original throw, which its ajax error callback only surfaces as a generic alert. ClearCalendar/ClearOld keep the original throw-on-denial behavior: unlike GenerateCalendar's view, CreateOrUpdate.Calendar.cshtml's success callback for these two actions never reads the response body at all (no else branch), so a 200 response on denial would silently refresh the grid as if the clear had succeeded - switching those two to Json would be a real UX regression versus the original throw's visible generic alert. The view not surfacing a specific denial message here is a pre-existing gap, out of scope for this migration. --- .../Controllers/BaseProductControllerTests.cs | 326 +++++++++++++++++- .../Controllers/BaseProductController.cs | 245 +++++++++++++ 2 files changed, 570 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index e978022933..fb2482a177 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -25,6 +25,7 @@ using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using System.Linq.Expressions; namespace Grand.Web.Admin.Tests.Controllers; @@ -60,6 +61,7 @@ private class TestProductController( private Mock _translationServiceMock; private Mock> _scopeMock; private Mock _permissionServiceMock; + private Mock _productReservationServiceMock; [TestInitialize] public void Setup() @@ -86,13 +88,15 @@ public void Setup() var languageServiceMock = new Mock(); languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + _productReservationServiceMock = new Mock(); + _controller = new TestProductController( _productViewModelServiceMock.Object, _productServiceMock.Object, new Mock().Object, languageServiceMock.Object, _translationServiceMock.Object, - new Mock().Object, + _productReservationServiceMock.Object, new Mock().Object, new Mock().Object, _permissionServiceMock.Object, @@ -5202,4 +5206,324 @@ public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_D _productViewModelServiceMock.Verify( s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); } + + // --- Reservation ------------------------------------------------------------------------------- + // ARCH-001 Phase 1 Task 8 row 23. Admin's originals had no ownership check at all on any of these + // four actions; Store used CanAccessProduct; Vendor used CheckAccessToProduct (List) or a combined + // null-or-HasAccessToProduct throw (the other three) - all normalized to scope.HasAccess. + + private static ProductReservation NewReservation(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListReservations ---------------------------------------------------------------------------- + + [TestMethod] + public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservations = new PagedList( + new List { NewReservation("r1", "p1") }, 0, 10); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) + .ReturnsAsync(reservations); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- GenerateCalendar ------------------------------------------------------------------------------ + + private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { + StartDate = new DateTime(2026, 1, 1), + EndDate = new DateTime(2026, 1, 1), + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 18, 0, 0), + Interval = 1, + IntervalUnit = (int)unit, + Quantity = 1, + Resource = "room1", + // 2026-01-01 is a Thursday. + Thursday = true + }; + + [TestMethod] + public async Task GenerateCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateCalendar("missing", ValidCalendarModel())); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _controller.ModelState.AddModelError("Resource", "Required"); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() + { + var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.Is(r => + r.ProductId == "p1" && r.Resource == "room1")), Times.Once); + _productServiceMock.Verify( + s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); + } + + // --- ClearCalendar ---------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); + } + + [TestMethod] + public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success + // callback for this action never reads the response body (no else branch) on any of the three + // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); + + var result = await _controller.ClearCalendar("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + // --- ClearOld --------------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearOld_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); + } + + [TestMethod] + public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer + // gap applies to this action's response handling. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; + var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList( + new List { oldReservation, futureReservation }, 0, int.MaxValue)); + + var result = await _controller.ClearOld("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); + } + + // --- ProductReservationDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductReservationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the + // access check ran against model.ProductId while the delete ran against model.ReservationId, with no + // check that the two referred to the same product. A caller with access to *any* product could pass + // that product's id for the access check while supplying the ReservationId of a reservation belonging + // to a different, unowned product, deleting it. + [TestMethod] + public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignReservation = NewReservation("r1", "p2"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1", "order1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 85c245d5d8..2812428805 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -3084,4 +3084,249 @@ await productViewModelService.DeleteProductAttributeCombinationTierPrices(produc } #endregion + + #region Reservation + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListReservations(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; + // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var reservations = + await productReservationService.GetProductReservationsByProductId(productId, null, null, + command.Page - 1, command.PageSize); + var reservationModel = reservations + .Select(x => new ProductModel.ReservationModel { + ReservationId = x.Id, + Date = x.Date, + OrderId = x.OrderId, + ProductId = x.ProductId, + Parameter = x.Parameter, + Resource = x.Resource, + Duration = x.Duration + }).ToList(); + + var gridModel = new DataSourceResult { + Data = reservationModel, + Total = reservations.TotalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct + // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw + // ArgumentException for the combined null-or-access-denied case instead, which the client's + // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors + // message here is strictly more informative and closes Admin's original gap (no check at all). + if (!await scope.HasAccess(product)) + return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); + + var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); + if (reservations.Any()) + if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && + (IntervalUnit)model.Interval == IntervalUnit.Day) || + (product.IntervalUnitId == IntervalUnit.Day && + ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || + (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) + return Json(new { + errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") + }); + + if (!ModelState.IsValid) + { + var error = (Dictionary>)ModelState.SerializeErrors(); + var s = ""; + foreach (var error1 in error) + foreach (var error2 in error1.Value) + { + var v = (string[])error2.Value; + s += v[0] + "\n"; + } + + return Json(new { errors = s }); + } + + //update fields on product + await productService.UpdateProductField(product, x => x.Interval, model.Interval); + await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); + await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); + + var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { + IntervalUnit.Minute => model.Interval, + IntervalUnit.Hour => model.Interval * 60, + IntervalUnit.Day => model.Interval * 60 * 24, + _ => 0 + }; + + var _hourFrom = model.StartTime.Hour; + var _minutesFrom = model.StartTime.Minute; + var _hourTo = model.EndTime.Hour; + var _minutesTo = model.EndTime.Minute; + var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, + 0, 0, 0, 0); + var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, + 59, 999); + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + { + model.Quantity = 1; + model.Parameter = ""; + } + else + { + model.Resource = ""; + } + + var dates = new List(); + var counter = 0; + for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) + { + if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) + { + if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) + { + if (iterator.Hour == _hourTo) + if (iterator.Minute > _minutesTo) + continue; + if (iterator.Hour == _hourFrom) + if (iterator.Minute < _minutesFrom) + continue; + } + else + { + continue; + } + } + + if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || + (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || + (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || + (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || + (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || + (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || + (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) + continue; + + for (var i = 0; i < model.Quantity.MaxQuantity(); i++) + { + dates.Add(iterator); + try + { + var insert = true; + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) + insert = false; + if (insert) + { + if (counter++ > 1000) + break; + + await productReservationService.InsertProductReservation(new ProductReservation { + OrderId = "", + Date = iterator, + ProductId = productId, + Resource = model.Resource, + Parameter = model.Parameter, + Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) + }); + } + } + catch { } + } + } + + return Json(new { success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearCalendar(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads + // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, + // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A + // thrown exception at least surfaces a generic error via the ajax error callback. The view not + // reading a denial message is a pre-existing gap, out of scope for this migration. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearOld(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success + // callback doesn't read the response body either. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = + (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => + x.Date < DateTime.UtcNow); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductReservationDelete(ProductModel.ReservationModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservation(model.ReservationId); + + // Cross-product IDOR closed here: none of the three original hosts verified that the reservation + // being deleted (looked up purely by model.ReservationId) actually belongs to the product just + // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store + // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access + // check with that product's id while supplying a ReservationId belonging to a different, unowned + // product, deleting a reservation on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + await productReservationService.DeleteProductReservation(toDelete); + else + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") + }); + } + + return Json(""); + } + + #endregion } From 871b0d2e48f59c5408bcb47c2c821747e1cbbcc3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 07:24:02 +0200 Subject: [PATCH 044/147] Migrate 'Bids' region into BaseProductController (ARCH-001 Phase 1) Row 24/24 (final region) of Task 8. Merges ListBids/BidDelete from Admin/Store/Vendor ProductControllers into BaseProductController, routing ownership checks through scope.HasAccess (Admin: no-op, Store: StaffStoreId, Vendor: VendorId). Fixes a confused-deputy IDOR in BidDelete identical in shape to the one fixed in ProductReservationDelete (row 23): the access check ran against model.ProductId while the bid being deleted was looked up independently by model.BidId, with no verification that the bid actually belonged to the access-checked product. A caller with access to any product could delete a bid on a product they have no access to by supplying that product's id for the access check and a foreign BidId for the delete. Closed by checking toDelete.ProductId == product.Id before deleting. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 161 +++++++++++++++++- .../Controllers/BaseProductController.cs | 65 +++++++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index fb2482a177..061a4a5045 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -62,6 +62,7 @@ private class TestProductController( private Mock> _scopeMock; private Mock _permissionServiceMock; private Mock _productReservationServiceMock; + private Mock _auctionServiceMock; [TestInitialize] public void Setup() @@ -89,6 +90,7 @@ public void Setup() languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); _productReservationServiceMock = new Mock(); + _auctionServiceMock = new Mock(); _controller = new TestProductController( _productViewModelServiceMock.Object, @@ -97,7 +99,7 @@ public void Setup() languageServiceMock.Object, _translationServiceMock.Object, _productReservationServiceMock.Object, - new Mock().Object, + _auctionServiceMock.Object, new Mock().Object, _permissionServiceMock.Object, new Mock().Object, @@ -5526,4 +5528,161 @@ public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_ _productReservationServiceMock.Verify( s => s.DeleteProductReservation(It.IsAny()), Times.Never); } + + // --- Bids ---------------------------------------------------------------------------------------- + // ARCH-001 Phase 1 Task 8 row 24 (final region). Admin's originals had no ownership check at all; + // Store used CanAccessProduct; Vendor used a combined null-or-HasAccessToProduct throw - all + // normalized to scope.HasAccess. + + private static Bid NewBid(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListBids -------------------------------------------------------------------------------- + + [TestMethod] + public async Task ListBids_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); + } + + [TestMethod] + public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListBids_ScopeGrantsAccess_ReturnsBids() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.ListBids( + new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); + + Assert.IsInstanceOfType(result); + var json = result as JsonResult; + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + } + + // --- BidDelete --------------------------------------------------------------------------------- + + [TestMethod] + public async Task BidDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); + } + + [TestMethod] + public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by + // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while + // the delete ran against model.BidId, with no check that the two referred to the same product. A + // caller with access to *any* product could pass that product's id for the access check while + // supplying the BidId of a bid belonging to a different, unowned product, deleting it. + [TestMethod] + public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignBid = NewBid("b1", "p2"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1", "order1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); + } + + [TestMethod] + public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual("Bid not exists", gridModel.Errors); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 2812428805..6bc2ab8cb5 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -3329,4 +3329,69 @@ public async Task ProductReservationDelete(ProductModel.Reservati } #endregion + + #region Bids + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListBids(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's + // original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var (bidModels, totalCount) = + await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bidModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BidDelete(ProductModel.BidModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await auctionService.GetBid(model.BidId); + + // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the + // three original hosts verified that the bid being deleted (looked up purely by model.BidId) + // actually belongs to the product just access-checked (model.ProductId). Both are independent, + // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has + // access to *any* product pass the access check using that product's id while supplying a BidId + // belonging to a different, unowned product, deleting a bid on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + { + //delete bid + await auctionService.DeleteBid(toDelete); + return Json(""); + } + + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") + }); + } + + return Json(new DataSourceResult { Errors = "Bid not exists" }); + } + + #endregion } From 06d4c9b4c2c188e6649342cdb31febb68fe36f2f Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 11:17:30 +0200 Subject: [PATCH 045/147] Drop storeId params from IProductViewModelService; inject IAdminDataScope (ARCH-001 Phase 1 Task 9) - IProductViewModelService/ProductViewModelService: removed storeId parameter from 13 Prepare*/tier-price methods, service now resolves scope internally via injected IAdminDataScope - Added ShowStoreSelector to IAdminDataScope; implemented in Global/Store/Vendor scopes (true/true/false) per PrepareProductListModel's store-picker requirement - Updated all BaseProductController.cs and Store's still-active ProductController.cs call sites to match the new signatures - All four host/test builds succeed; Grand.Web.Admin.Tests (337), Grand.Web.Store.Tests (122), Grand.Web.Vendor.Tests (19) all pass unfiltered --- .../Controllers/BaseProductControllerTests.cs | 253 ++++-------------- .../Services/GlobalAdminDataScopeTests.cs | 7 + .../Services/ProductViewModelServiceTests.cs | 51 +++- .../Services/StoreAdminDataScopeTests.cs | 7 + .../Services/VendorProductDataScopeTests.cs | 7 + .../Controllers/BaseProductController.cs | 87 +++--- .../Interfaces/IAdminDataScope.cs | 7 + .../Interfaces/IProductViewModelService.cs | 24 +- .../Services/GlobalAdminDataScope.cs | 2 + .../Services/ProductViewModelService.cs | 136 ++++++---- .../Services/StoreAdminDataScope.cs | 2 + .../Services/VendorProductDataScope.cs | 2 + .../Controllers/ProductController.cs | 42 +-- 13 files changed, 297 insertions(+), 330 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 061a4a5045..785128c8d5 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -449,15 +449,16 @@ public async Task GoToSku_ScopeGrantsAccess_RedirectsToEdit() // --- List / Create default store-scoping ----------------------------------------------------------- [TestMethod] - public async Task List_UsesScopeDefaultStoreId() + public async Task List_CallsPrepareProductListModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareProductListModel("store-1")).ReturnsAsync(new ProductListModel()); + // PrepareProductListModel() now reads scope.DefaultStoreId internally (Task 9) - the controller + // no longer threads it through as an argument. + _productViewModelServiceMock.Setup(s => s.PrepareProductListModel()).ReturnsAsync(new ProductListModel()); var result = await _controller.List(); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductListModel("store-1"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductListModel(), Times.Once); } [TestMethod] @@ -531,32 +532,19 @@ private static string GetTextProperty(object value) => // --- RequiredProductAddPopup ---------------------------------------------------------------------- [TestMethod] - public async Task RequiredProductAddPopup_UsesScopeDefaultStoreId() + public async Task RequiredProductAddPopup_CallsPrepareAddRequiredProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel("store-1")) + // PrepareAddRequiredProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel()) .ReturnsAsync(new ProductModel.AddRequiredProductModel()); var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel("store-1"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(), Times.Once); Assert.AreEqual("input1", _controller.ViewBag.productIdsInput); } - [TestMethod] - public async Task RequiredProductAddPopup_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareAddRequiredProductModel("")) - .ReturnsAsync(new ProductModel.AddRequiredProductModel()); - - var result = await _controller.RequiredProductAddPopup("input1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareAddRequiredProductModel(""), Times.Once); - } - // --- RequiredProductAddPopupList ------------------------------------------------------------------- [TestMethod] @@ -991,10 +979,10 @@ public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- [TestMethod] - public async Task RelatedProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("store-1")) + // PrepareRelatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) .ReturnsAsync(new ProductModel.AddRelatedProductModel()); var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; @@ -1003,20 +991,7 @@ public async Task RelatedProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddRelatedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task RelatedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("")) - .ReturnsAsync(new ProductModel.AddRelatedProductModel()); - - var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); } // --- RelatedProductAddPopupList ----------------------------------------------------------------- @@ -1095,9 +1070,8 @@ public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddRelatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -1208,10 +1182,10 @@ public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- [TestMethod] - public async Task SimilarProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("store-1")) + // PrepareSimilarProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) .ReturnsAsync(new ProductModel.AddSimilarProductModel()); var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; @@ -1220,20 +1194,7 @@ public async Task SimilarProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddSimilarProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task SimilarProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("")) - .ReturnsAsync(new ProductModel.AddSimilarProductModel()); - - var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); } // --- SimilarProductAddPopupList ----------------------------------------------------------------- @@ -1312,9 +1273,8 @@ public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retu var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddSimilarProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -1425,10 +1385,10 @@ public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() // --- BundleProductAddPopup (GET) -------------------------------------------------------------- [TestMethod] - public async Task BundleProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("store-1")) + // PrepareBundleProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) .ReturnsAsync(new ProductModel.AddBundleProductModel()); var result = await _controller.BundleProductAddPopup("p1") as ViewResult; @@ -1437,20 +1397,7 @@ public async Task BundleProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddBundleProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task BundleProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("")) - .ReturnsAsync(new ProductModel.AddBundleProductModel()); - - var result = await _controller.BundleProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); } // --- BundleProductAddPopupList ----------------------------------------------------------------- @@ -1529,9 +1476,8 @@ public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Retur var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddBundleProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -1636,10 +1582,10 @@ public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- [TestMethod] - public async Task CrossSellProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("store-1")) + // PrepareCrossSellProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; @@ -1648,20 +1594,7 @@ public async Task CrossSellProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddCrossSellProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task CrossSellProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("")) - .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); - - var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); } // --- CrossSellProductAddPopupList --------------------------------------------------------------- @@ -1740,9 +1673,8 @@ public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_Re var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddCrossSellProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -1848,10 +1780,10 @@ public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes( // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- [TestMethod] - public async Task RecommendedProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("store-1")) + // PrepareRecommendedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; @@ -1860,20 +1792,7 @@ public async Task RecommendedProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddRecommendedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task RecommendedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("")) - .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); - - var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); } // --- RecommendedProductAddPopupList --------------------------------------------------------------- @@ -1952,9 +1871,8 @@ public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddRecommendedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -2085,10 +2003,10 @@ public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- [TestMethod] - public async Task AssociatedProductAddPopupGet_UsesScopeDefaultStoreId() + public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("store-1")) + // PrepareAssociatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; @@ -2097,20 +2015,7 @@ public async Task AssociatedProductAddPopupGet_UsesScopeDefaultStoreId() var model = result.Model as ProductModel.AddAssociatedProductModel; Assert.IsNotNull(model); Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel("store-1"), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductAddPopupGet_NoDefaultStoreId_PassesEmptyString() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("")) - .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); - - var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); } // --- AssociatedProductAddPopupList --------------------------------------------------------------- @@ -2238,9 +2143,8 @@ public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_R var parent = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); var reprepared = new ProductModel.AddAssociatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel("store-1")).ReturnsAsync(reprepared); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; _controller.ModelState.AddModelError("x", "error"); @@ -3008,29 +2912,16 @@ public async Task ImportExcel_ImportThrows_RedirectsToListWithError() // --- Bulk editing -------------------------------------------------------------------------------- [TestMethod] - public async Task BulkEdit_UsesScopeDefaultStoreId() + public async Task BulkEdit_CallsPrepareBulkEditListModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel("store1")) + // PrepareBulkEditListModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel()) .ReturnsAsync(new BulkEditListModel()); var result = await _controller.BulkEdit(); Assert.IsInstanceOfType(result, typeof(ViewResult)); - _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel("store1"), Times.Once); - } - - [TestMethod] - public async Task BulkEdit_NullDefaultStoreId_PassesEmptyString() - { - // Admin/Vendor: scope.DefaultStoreId is null (Admin is global; Vendor is not store-scoped) - - // matches Admin's original parameterless call and Vendor's own service's parameterless method. - _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel("")) - .ReturnsAsync(new BulkEditListModel()); - - await _controller.BulkEdit(); - - _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(""), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(), Times.Once); } [TestMethod] @@ -3335,7 +3226,7 @@ public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() Assert.IsInstanceOfType(result); _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny(), It.IsAny()), Times.Never); + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); } [TestMethod] @@ -3354,14 +3245,14 @@ public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() } [TestMethod] - public async Task TierPriceList_ScopeGrantsAccess_UsesScopeDefaultStoreId_ReturnsGrid() + public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() { + // PrepareTierPriceModel(product) now reads scope internally (Task 9). var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); var tierPrices = new List { new() { Id = "tp1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product, "store1")).ReturnsAsync(tierPrices); + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); @@ -3372,33 +3263,17 @@ public async Task TierPriceList_ScopeGrantsAccess_UsesScopeDefaultStoreId_Return Assert.AreEqual(1, gridModel.Total); } - [TestMethod] - public async Task TierPriceList_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyString() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product, "")) - .ReturnsAsync(new List()); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareTierPriceModel(product, ""), Times.Once); - } - // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- [TestMethod] - public async Task TierPriceCreatePopup_Get_PreparesModelWithScopeDefaultStoreId() + public async Task TierPriceCreatePopup_Get_PreparesModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - + // PrepareTierPriceModel(model) now reads scope internally (Task 9). var result = await _controller.TierPriceCreatePopup("p1"); Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1"), "store1"), + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), Times.Once); } @@ -3459,7 +3334,7 @@ public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProduct Assert.IsNotNull(content); Assert.AreEqual("This is not your product", content.Content); _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny(), It.IsAny()), Times.Never); + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); } [TestMethod] @@ -3477,19 +3352,19 @@ public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_Retu } [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModelWithScopeDefaultStoreId() + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). var product = new Product { Id = "p1" }; product.TierPrices.Add(new TierPrice { Id = "tp1" }); _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); var result = await _controller.TierPriceEditPopup("tp1", "p1"); Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1"), "store1"), + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), Times.Once); } @@ -4598,31 +4473,19 @@ public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelStat // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Get_PassesScopeDefaultStoreId() + public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel("store1")) + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); var result = await _controller.AssociateProductToAttributeValuePopup(); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel("store1"), + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), Times.Once); } - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Get_NullDefaultStoreId_PassesEmptyString() - { - _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel("")) - .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); - - var result = await _controller.AssociateProductToAttributeValuePopup(); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(""), Times.Once); - } - // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- [TestMethod] diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs index 18148315e6..1f01ea0bd8 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/GlobalAdminDataScopeTests.cs @@ -39,4 +39,11 @@ public void ResourceKeyPrefix_IsAdmin() var scope = new GlobalAdminDataScope(); Assert.AreEqual("Admin", scope.ResourceKeyPrefix); } + + [TestMethod] + public void ShowStoreSelector_IsTrue() + { + var scope = new GlobalAdminDataScope(); + Assert.IsTrue(scope.ShowStoreSelector); + } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 956f0b5156..d0b401084a 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -23,6 +23,7 @@ using Grand.Domain.Stores; using Grand.Domain.Tax; using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Services; using Grand.Web.Common.Localization; @@ -42,6 +43,7 @@ public class ProductViewModelServiceTests private Mock _enumTranslationServiceMock; private Mock _measureServiceMock; private ProductViewModelService _productViewModelService; + private Mock> _scopeMock; private Mock _storeServiceMock; private Mock _taxCategoryServiceMock; private Mock _translationServiceMock; @@ -98,6 +100,12 @@ public void Setup() .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new SelectList(Enumerable.Empty())); + // Default: Admin's Global scope - no default store, homepage option and store dropdown both show. + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _productViewModelService = new ProductViewModelService( new Mock().Object, new Mock().Object, @@ -135,7 +143,8 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - _enumTranslationServiceMock.Object); + _enumTranslationServiceMock.Object, + _scopeMock.Object); } [TestMethod] @@ -179,11 +188,12 @@ public async Task PrepareProductModel_UseModelStoreIdForWarehouses() [TestMethod] public async Task PrepareProductListModel_UseStoreIdForWarehouses() { + _scopeMock.Setup(s => s.DefaultStoreId).Returns(StaffStoreId); _warehouseServiceMock .Setup(w => w.GetAllWarehouses(StaffStoreId, It.IsAny(), It.IsAny())) .ReturnsAsync(new PagedList { new() { Id = "warehouseId", Name = "Main" } }); - var model = await _productViewModelService.PrepareProductListModel(StaffStoreId); + var model = await _productViewModelService.PrepareProductListModel(); _warehouseServiceMock.Verify(w => w.GetAllWarehouses(StaffStoreId, It.IsAny(), It.IsAny()), Times.Once); @@ -193,14 +203,49 @@ public async Task PrepareProductListModel_UseStoreIdForWarehouses() [TestMethod] public async Task PrepareProductListModel_FilterStoresByStoreId() { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { new() { Id = "store1", Shortcut = "Store 1" }, new() { Id = "store2", Shortcut = "Store 2" } }); - var model = await _productViewModelService.PrepareProductListModel("store1"); + var model = await _productViewModelService.PrepareProductListModel(); Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1")); Assert.IsFalse(model.AvailableStores.Any(x => x.Value == "store2")); } + + [TestMethod] + public async Task PrepareProductListModel_GlobalScope_IncludesHomepageOptionAndStoreDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = await _productViewModelService.PrepareProductListModel(); + + Assert.IsTrue(model.AvailablePublishedOptions.Any(x => x.Value == "3"), + "Admin should offer the 'Show on homepage' option."); + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), + "Admin should offer a store dropdown."); + } + + [TestMethod] + public async Task PrepareProductListModel_VendorScope_HidesHomepageOptionAndStoreDropdown() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = await _productViewModelService.PrepareProductListModel(); + + Assert.IsFalse(model.AvailablePublishedOptions.Any(x => x.Value == "3"), + "Vendor can't feature products on the homepage."); + Assert.IsFalse(model.AvailableStores.Any(), + "Vendor doesn't pick stores - no dropdown should be populated at all."); + } } diff --git a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs index 0b3f56b478..f0326fea8d 100644 --- a/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Services/StoreAdminDataScopeTests.cs @@ -83,6 +83,13 @@ public void ResourceKeyPrefix_IsAdmin() Assert.AreEqual("Admin", scope.ResourceKeyPrefix); } + [TestMethod] + public void ShowStoreSelector_IsTrue() + { + var scope = new StoreAdminDataScope(_contextAccessor.Object); + Assert.IsTrue(scope.ShowStoreSelector); + } + // CanView is deliberately looser than HasAccess: it mirrors Store's original Edit(GET)/CopyProduct // rule (a global or multi-store product including the staff member's store may be viewed/copied; // only a product limited to stores that exclude the staff member's store is denied). See the diff --git a/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs index cbbd948665..39772d5c75 100644 --- a/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs +++ b/src/Tests/Grand.Web.Vendor.Tests/Services/VendorProductDataScopeTests.cs @@ -73,4 +73,11 @@ public void ResourceKeyPrefix_IsVendor() var scope = new VendorProductDataScope(_contextAccessor.Object); Assert.AreEqual("Vendor", scope.ResourceKeyPrefix); } + + [TestMethod] + public void ShowStoreSelector_IsFalse() + { + var scope = new VendorProductDataScope(_contextAccessor.Object); + Assert.IsFalse(scope.ShowStoreSelector); + } } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 6bc2ab8cb5..2b84760980 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -94,7 +94,7 @@ protected virtual void EditWarningCheck(Product product) { } public async Task List() { - var model = await productViewModelService.PrepareProductListModel(scope.DefaultStoreId ?? ""); + var model = await productViewModelService.PrepareProductListModel(); return View(model); } @@ -369,10 +369,10 @@ public async Task LoadProductFriendlyNames(string productIds) [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task RequiredProductAddPopup(string productIdsInput) { - // scope.DefaultStoreId already encodes the per-host default exactly: null for Admin (global) and - // Vendor (not store-scoped), StaffStoreId for Store - matching Store's original - // PrepareAddRequiredProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareAddRequiredProductModel(scope.DefaultStoreId ?? ""); + // PrepareAddRequiredProductModel() now reads scope internally (Task 9) - the injected + // IAdminDataScope already encodes the per-host default exactly: null for Admin + // (global) and Vendor (not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareAddRequiredProductModel(); // Unused by any of the three views (all three read productIdsInput straight off the query string // via Context.Request.Query, not ViewBag), but Admin and Vendor both set it and Store silently // drops its own parameter - kept here for parity; it is inert either way. @@ -657,9 +657,9 @@ public async Task RelatedProductAddPopup(string productId) { // No access check here in any of the three original hosts (Admin/Store/Vendor all open this // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareRelatedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareRelatedProductModel(scope.DefaultStoreId ?? ""); + // ties access to a specific product. PrepareRelatedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRelatedProductModel(); model.ProductId = productId; return View(model); } @@ -710,7 +710,7 @@ public async Task RelatedProductAddPopup(ProductModel.AddRelatedP protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareRelatedProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareRelatedProductModel(); return View(model); } @@ -790,9 +790,9 @@ public async Task SimilarProductAddPopup(string productId) { // No access check here in any of the three original hosts (Admin/Store/Vendor all open this // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareSimilarProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareSimilarProductModel(scope.DefaultStoreId ?? ""); + // ties access to a specific product. PrepareSimilarProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareSimilarProductModel(); model.ProductId = productId; return View(model); } @@ -843,7 +843,7 @@ public async Task SimilarProductAddPopup(ProductModel.AddSimilarP protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareSimilarProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareSimilarProductModel(); return View(model); } @@ -923,9 +923,9 @@ public async Task BundleProductAddPopup(string productId) { // No access check here in any of the three original hosts (Admin/Store/Vendor all open this // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareBundleProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareBundleProductModel(scope.DefaultStoreId ?? ""); + // ties access to a specific product. PrepareBundleProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareBundleProductModel(); model.ProductId = productId; return View(model); } @@ -976,7 +976,7 @@ public async Task BundleProductAddPopup(ProductModel.AddBundlePro protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareBundleProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareBundleProductModel(); return View(model); } @@ -1045,9 +1045,9 @@ public async Task CrossSellProductAddPopup(string productId) { // No access check here in any of the three original hosts (Admin/Store/Vendor all open this // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareCrossSellProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareCrossSellProductModel(scope.DefaultStoreId ?? ""); + // ties access to a specific product. PrepareCrossSellProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareCrossSellProductModel(); model.ProductId = productId; return View(model); } @@ -1098,7 +1098,7 @@ public async Task CrossSellProductAddPopup(ProductModel.AddCrossS protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareCrossSellProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareCrossSellProductModel(); return View(model); } @@ -1167,9 +1167,9 @@ public async Task RecommendedProductAddPopup(string productId) { // No access check here in any of the three original hosts (Admin/Store/Vendor all open this // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareRecommendedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareRecommendedProductModel(scope.DefaultStoreId ?? ""); + // ties access to a specific product. PrepareRecommendedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRecommendedProductModel(); model.ProductId = productId; return View(model); } @@ -1220,7 +1220,7 @@ public async Task RecommendedProductAddPopup(ProductModel.AddReco protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) { Error(ModelState); - model = await productViewModelService.PrepareRecommendedProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareRecommendedProductModel(); return View(model); } @@ -1315,9 +1315,8 @@ public async Task AssociatedProductAddPopup(string productId) { // No access check here in any of the three original hosts (all open this popup unconditionally // once the Edit permission is satisfied) - only the mutating actions below tie access to a - // specific product. scope.DefaultStoreId ?? "" matches Store's - // PrepareAssociatedProductModel(StaffStoreId) call and Admin/Vendor's parameterless call. - var model = await productViewModelService.PrepareAssociatedProductModel(scope.DefaultStoreId ?? ""); + // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). + var model = await productViewModelService.PrepareAssociatedProductModel(); model.ProductId = productId; return View(model); } @@ -1389,7 +1388,7 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it // uses in those other regions, so no host-specific hook is needed for this action. Error(ModelState); - model = await productViewModelService.PrepareAssociatedProductModel(scope.DefaultStoreId ?? ""); + model = await productViewModelService.PrepareAssociatedProductModel(); return View(model); } @@ -1941,11 +1940,9 @@ public async Task ImportExcel(IFormFile importexcelfile, [PermissionAuthorizeAction(PermissionActionName.Preview)] public async Task BulkEdit() { - // scope.DefaultStoreId already encodes the per-host default: null for Admin/Vendor (Admin's - // original called PrepareBulkEditListModel() with no storeId; Vendor's own separate service - // (Grand.Web.Vendor.Interfaces.IProductViewModelService.PrepareBulkEditListModel) takes no - // storeId parameter at all - not store-scoped), StaffStoreId for Store. - var model = await productViewModelService.PrepareBulkEditListModel(scope.DefaultStoreId ?? ""); + // PrepareBulkEditListModel() now reads scope internally (Task 9): null for Admin/Vendor + // (not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareBulkEditListModel(); return View(model); } @@ -2199,10 +2196,9 @@ public async Task TierPriceList(DataSourceRequest command, string if (!await scope.HasAccess(product)) return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - // Old storeId-parameter overload (still present pending Task 9/10); scope.DefaultStoreId is null for - // Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store, same as the other rows - // still on this signature. - var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product, scope.DefaultStoreId ?? ""); + // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId + // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. + var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); var gridModel = new DataSourceResult { Data = tierPricesModel, Total = tierPricesModel.Count @@ -2216,7 +2212,7 @@ public async Task TierPriceCreatePopup(string productId) var model = new ProductModel.TierPriceModel { ProductId = productId }; - await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + await productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -2243,7 +2239,7 @@ public async Task TierPriceCreatePopup(ProductModel.TierPriceMode Error(ModelState); //If we got this far, something failed, redisplay form - await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + await productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -2267,7 +2263,7 @@ public async Task TierPriceEditPopup(string id, string productId) var model = tierPrice.ToModel(dateTimeService); model.ProductId = productId; - await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + await productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -2297,7 +2293,7 @@ public async Task TierPriceEditPopup(string productId, ProductMod Error(ModelState); //stores - await productViewModelService.PrepareTierPriceModel(model, scope.DefaultStoreId ?? ""); + await productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -2767,11 +2763,10 @@ public async Task ProductAttributeValueDelete(string id, string p public async Task AssociateProductToAttributeValuePopup() { - // scope.DefaultStoreId ?? "": matches Store's original (passed StaffStoreId to scope the search to - // the staff member's store); null for Admin/Vendor (no store concept), matching their originals - // (no argument, defaulting to ""). + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): + // StaffStoreId for Store; null for Admin/Vendor (no store concept). var model = - await productViewModelService.PrepareAssociateProductToAttributeValueModel(scope.DefaultStoreId ?? ""); + await productViewModelService.PrepareAssociateProductToAttributeValueModel(); return View(model); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs index 9f50b84ab2..d62b6c4420 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -31,4 +31,11 @@ public interface IAdminDataScope /// Prefix used to build host-specific localization keys, e.g. "Admin", "Vendor". Store /// currently has no distinct resource set and uses "Admin" (see Task 6). string ResourceKeyPrefix { get; } + + /// Whether the host's product list/search screens should offer a store picker at all. + /// True for Admin and Store (both operate within a store concept); false for Vendor (vendors don't + /// pick stores - the whole point of vendor scope is that it isn't a store id). This is a capability + /// flag, deliberately distinct from being null: DefaultStoreId is also + /// null for Admin (global, no default store), where the selector should still show. + bool ShowStoreSelector { get; } } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 36710c8a22..5e6f298762 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -19,9 +19,9 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareTierPriceModel(ProductModel.TierPriceModel model, string storeId = ""); + Task PrepareTierPriceModel(ProductModel.TierPriceModel model); Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); - Task PrepareProductListModel(string storeId = ""); + Task PrepareProductListModel(); Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel(ProductListModel model, int pageIndex, int pageSize); @@ -31,7 +31,7 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task UpdateProductModel(Product product, ProductModel model); Task DeleteProduct(Product product); Task DeleteSelected(IEnumerable selectedIds); - Task PrepareAddRequiredProductModel(string storeId = ""); + Task PrepareAddRequiredProductModel(); Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, int pageIndex, int pageSize); @@ -59,13 +59,13 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteRecommendedProduct(string productId, string recommendedProductId); Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); Task DeleteAssociatedProduct(Product product); - Task PrepareRelatedProductModel(string storeId = ""); - Task PrepareSimilarProductModel(string storeId = ""); - Task PrepareBundleProductModel(string storeId = ""); - Task PrepareCrossSellProductModel(string storeId = ""); - Task PrepareRecommendedProductModel(string storeId = ""); - Task PrepareAssociatedProductModel(string storeId = ""); - Task PrepareBulkEditListModel(string storeId = ""); + Task PrepareRelatedProductModel(); + Task PrepareSimilarProductModel(); + Task PrepareBundleProductModel(); + Task PrepareCrossSellProductModel(); + Task PrepareRecommendedProductModel(); + Task PrepareAssociatedProductModel(); + Task PrepareBulkEditListModel(); Task<(IEnumerable bulkEditProductModels, int totalCount)> PrepareBulkEditProductModel( BulkEditListModel model, int pageIndex, int pageSize); @@ -75,7 +75,7 @@ Task OutOfStockNotifications(Product product, ProductAttributeCombination combin Task DeleteBulkEdit(IEnumerable products); //tierprices - Task> PrepareTierPriceModel(Product product, string storeId = ""); + Task> PrepareTierPriceModel(Product product); Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, int pageSize); @@ -117,7 +117,7 @@ Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMappi Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); Task - PrepareAssociateProductToAttributeValueModel(string storeId = ""); + PrepareAssociateProductToAttributeValueModel(); Task> PrepareProductAttributeCombinationModel(Product product); diff --git a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs index 2f28984ca4..9c1d854424 100644 --- a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs @@ -13,4 +13,6 @@ public class GlobalAdminDataScope : IAdminDataScope public string? DefaultStoreId => null; public string ResourceKeyPrefix => "Admin"; + + public bool ShowStoreSelector => true; } diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 4264906815..fddc9246cc 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -71,7 +71,8 @@ public class ProductViewModelService( IAuctionService auctionService, IPriceFormatter priceFormatter, ISeNameService seNameService, - IEnumTranslationService enumTranslationService) + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) : IProductViewModelService { public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, @@ -139,8 +140,9 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; } - public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model, string storeId = "") + public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) { + var storeId = scope.DefaultStoreId ?? ""; if (string.IsNullOrEmpty(storeId)) model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); @@ -518,24 +520,30 @@ public virtual async Task PrepareProductReviewModel(ProductReviewModel model, } } - public virtual async Task PrepareProductListModel(string storeId = "") + public virtual async Task PrepareProductListModel() { var model = new ProductListModel(); + var storeId = scope.DefaultStoreId ?? ""; + + //stores - Vendor has no store concept at all (ShowStoreSelector is false), so the dropdown + //is left empty entirely rather than populated off storeId, which is "" for Vendor same as Admin. + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } - //stores - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var s in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); //warehouses - model.AvailableWarehouses.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + model.AvailableWarehouses.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = " " }); foreach (var wh in await warehouseService.GetAllWarehouses(storeId)) model.AvailableWarehouses.Add(new SelectListItem { Text = wh.Name, Value = wh.Id }); //product types model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); + new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Common.All"), Value = "0" }); //"published" property //0 - all (according to "ShowHidden" parameter) @@ -543,21 +551,30 @@ public virtual async Task PrepareProductListModel(string store //2 - unpublished only //3 - Show on homepage //4 - mark as new - model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.All"), Value = " " }); + model.AvailablePublishedOptions.Add(new SelectListItem { Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.All"), Value = " " }); model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.PublishedOnly"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.PublishedOnly"), Value = "1" }); model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.UnpublishedOnly"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.UnpublishedOnly"), Value = "2" }); + + // Admin/Store show "Show on homepage" (value 3); Vendor's original copy omits it entirely - vendors + // can't feature products on the homepage, a real capability difference, not a naming difference. + // Using ResourceKeyPrefix as the gate works today (only Vendor differs) but is semantically about + // capability, not localization - if a fourth host is ever added, replace this with a proper + // bool CanFeatureOnHomepage on IAdminDataScope rather than continuing to overload + // ResourceKeyPrefix for behavior gating. + if (scope.ResourceKeyPrefix != "Vendor") + model.AvailablePublishedOptions.Add(new SelectListItem { + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.ShowOnHomePage"), + Value = "3" + }); + model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.ShowOnHomePage"), - Value = "3" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Admin.Catalog.Products.List.SearchPublished.MarkAsNew"), + Text = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.List.SearchPublished.MarkAsNew"), Value = "4" }); @@ -807,9 +824,9 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) } } - public virtual async Task PrepareAddRequiredProductModel(string storeId = "") + public virtual async Task PrepareAddRequiredProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } @@ -1155,44 +1172,45 @@ public virtual async Task DeleteAssociatedProduct(Product product) await productService.UpdateAssociatedProduct(product); } - public virtual async Task PrepareRelatedProductModel(string storeId = "") + public virtual async Task PrepareRelatedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareSimilarProductModel(string storeId = "") + public virtual async Task PrepareSimilarProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareBundleProductModel(string storeId = "") + public virtual async Task PrepareBundleProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareCrossSellProductModel(string storeId = "") + public virtual async Task PrepareCrossSellProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareRecommendedProductModel(string storeId = "") + public virtual async Task PrepareRecommendedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareAssociatedProductModel(string storeId = "") + public virtual async Task PrepareAssociatedProductModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } - public virtual async Task PrepareBulkEditListModel(string storeId = "") + public virtual async Task PrepareBulkEditListModel() { + var storeId = scope.DefaultStoreId ?? ""; var model = new BulkEditListModel(); //product types @@ -1200,19 +1218,24 @@ public virtual async Task PrepareBulkEditListModel(string sto model.AvailableProductTypes.Insert(0, new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "0" }); - // avaible stores - if (!string.IsNullOrEmpty(storeId)) + // avaible stores - same capability gate as PrepareProductListModel: Vendor's original never + // populated a stores dropdown here at all (vendors don't pick stores), so this must not run for + // Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) { - var store = (await storeService.GetAllStores()).FirstOrDefault(x => x.Id == storeId); - if (store != null) - model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); - } - else - { - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + if (!string.IsNullOrEmpty(storeId)) + { + var store = (await storeService.GetAllStores()).FirstOrDefault(x => x.Id == storeId); + if (store != null) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } + else + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var s in await storeService.GetAllStores()) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + foreach (var s in await storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } } return model; @@ -1296,8 +1319,9 @@ public virtual async Task DeleteBulkEdit(IEnumerable produ } } - public virtual async Task> PrepareTierPriceModel(Product product, string storeId = "") + public virtual async Task> PrepareTierPriceModel(Product product) { + var storeId = scope.DefaultStoreId ?? ""; var items = new List(); foreach (var x in product.TierPrices .Where(x => x.StoreId == storeId || string.IsNullOrWhiteSpace(storeId) || @@ -1868,9 +1892,9 @@ await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, model.ProductAttributeMappingId); } - public virtual async Task PrepareAssociateProductToAttributeValueModel(string storeId = "") + public virtual async Task PrepareAssociateProductToAttributeValueModel() { - var model = await PrepareAddProductModel(storeId); + var model = await PrepareAddProductModel(); return model; } @@ -2547,15 +2571,21 @@ protected virtual async Task SaveProductTags(Product product, string[] productTa } } - protected virtual async Task PrepareAddProductModel(string storeId = "") where T : ProductModel.AddProductModel, new() + protected virtual async Task PrepareAddProductModel() where T : ProductModel.AddProductModel, new() { + var storeId = scope.DefaultStoreId ?? ""; var model = new T(); - //stores - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var s in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareAddProductModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var s in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + } //vendors model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs index 6e5848922b..632fbd17c3 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -39,4 +39,6 @@ public IQueryable ApplyScope(IQueryable query) public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; public string ResourceKeyPrefix => "Admin"; + + public bool ShowStoreSelector => true; } diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs index 86e7cce3cf..c916139a4d 100644 --- a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs @@ -23,4 +23,6 @@ public IQueryable ApplyScope(IQueryable query) public string? DefaultStoreId => null; public string ResourceKeyPrefix => "Vendor"; + + public bool ShowStoreSelector => false; } diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 33c6b06316..a53df58e1b 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -101,7 +101,7 @@ public IActionResult Index() public async Task List() { - var model = await _productViewModelService.PrepareProductListModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareProductListModel(); return View(model); } @@ -368,7 +368,7 @@ public async Task LoadProductFriendlyNames(string productIds) [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task RequiredProductAddPopup(string productIdsInput) { - var model = await _productViewModelService.PrepareAddRequiredProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareAddRequiredProductModel(); return View(model); } @@ -621,7 +621,7 @@ public async Task RelatedProductDelete(ProductModel.RelatedProduc [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task RelatedProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareRelatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareRelatedProductModel(); model.ProductId = productId; return View(model); } @@ -656,7 +656,7 @@ public async Task RelatedProductAddPopup(ProductModel.AddRelatedP } Error(ModelState); - model = await _productViewModelService.PrepareRelatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareRelatedProductModel(); return View(model); } @@ -729,7 +729,7 @@ public async Task SimilarProductDelete(ProductModel.SimilarProduc [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task SimilarProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareSimilarProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareSimilarProductModel(); model.ProductId = productId; return View(model); } @@ -764,7 +764,7 @@ public async Task SimilarProductAddPopup(ProductModel.AddSimilarP } Error(ModelState); - model = await _productViewModelService.PrepareSimilarProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareSimilarProductModel(); return View(model); } @@ -837,7 +837,7 @@ public async Task BundleProductDelete(ProductModel.BundleProductM [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task BundleProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareBundleProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareBundleProductModel(); model.ProductId = productId; return View(model); } @@ -872,7 +872,7 @@ public async Task BundleProductAddPopup(ProductModel.AddBundlePro } Error(ModelState); - model = await _productViewModelService.PrepareBundleProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareBundleProductModel(); return View(model); } @@ -931,7 +931,7 @@ public async Task CrossSellProductDelete(ProductModel.CrossSellPr [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task CrossSellProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareCrossSellProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareCrossSellProductModel(); model.ProductId = productId; return View(model); } @@ -966,7 +966,7 @@ public async Task CrossSellProductAddPopup(ProductModel.AddCrossS } Error(ModelState); - model = await _productViewModelService.PrepareCrossSellProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareCrossSellProductModel(); return View(model); } @@ -1024,7 +1024,7 @@ public async Task RecommendedProductDelete(ProductModel.Recommend [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task RecommendedProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareRecommendedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareRecommendedProductModel(); model.ProductId = productId; return View(model); } @@ -1059,7 +1059,7 @@ public async Task RecommendedProductAddPopup(ProductModel.AddReco } Error(ModelState); - model = await _productViewModelService.PrepareRecommendedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareRecommendedProductModel(); return View(model); } @@ -1140,7 +1140,7 @@ public async Task AssociatedProductDelete(ProductModel.Associated [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task AssociatedProductAddPopup(string productId) { - var model = await _productViewModelService.PrepareAssociatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareAssociatedProductModel(); model.ProductId = productId; return View(model); } @@ -1187,7 +1187,7 @@ public async Task AssociatedProductAddPopup(ProductModel.AddAssoc } Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + model = await _productViewModelService.PrepareAssociatedProductModel(); return View(model); } @@ -1545,7 +1545,7 @@ public async Task Reviews(DataSourceRequest command, string produ [PermissionAuthorizeAction(PermissionActionName.Preview)] public async Task BulkEdit() { - var model = await _productViewModelService.PrepareBulkEditListModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareBulkEditListModel(); return View(model); } @@ -1752,7 +1752,7 @@ public async Task TierPriceList(DataSourceRequest command, string if (!CanAccessProduct(product)) return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); var gridModel = new DataSourceResult { Data = tierPricesModel, Total = tierPricesModel.Count @@ -1766,7 +1766,7 @@ public async Task TierPriceCreatePopup(string productId) var model = new ProductModel.TierPriceModel { ProductId = productId }; - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + await _productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -1791,7 +1791,7 @@ public async Task TierPriceCreatePopup(ProductModel.TierPriceMode Error(ModelState); //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + await _productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -1808,7 +1808,7 @@ public async Task TierPriceEditPopup(string id, string productId) var model = tierPrice.ToModel(_dateTimeService); model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + await _productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -1837,7 +1837,7 @@ public async Task TierPriceEditPopup(string productId, ProductMod Error(ModelState); //stores - await _productViewModelService.PrepareTierPriceModel(model, _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + await _productViewModelService.PrepareTierPriceModel(model); return View(model); } @@ -2236,7 +2236,7 @@ public async Task ProductAttributeValueDelete(string id, string p public async Task AssociateProductToAttributeValuePopup() { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); + var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); return View(model); } From 526beee875120a38ba4cacd3de8a871ca526be78 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 11:33:12 +0200 Subject: [PATCH 046/147] Task 9 fix round 1: gate PrepareTierPriceModel's store dropdown by ShowStoreSelector; correct report test count - ProductViewModelService.PrepareTierPriceModel(model): wrap AvailableStores population in scope.ShowStoreSelector, matching the pattern already applied to PrepareProductListModel/PrepareBulkEditListModel/PrepareAddProductModel in the same task. Vendor's original service never populated this dropdown. - Add PrepareTierPriceModel_VendorScope_HidesStoreDropdown / _GlobalScope_IncludesStoreDropdown tests; wire IGroupService/ICurrencyService.GetAllCurrencies mocks that were previously unconfigured (method was untested before this fix). - Correct task-9-report.md's stale Admin.Tests count (337 -> 393 pre-existing, 395 total after this fix). --- .../Services/ProductViewModelServiceTests.cs | 40 ++++++++++++++++++- .../Services/ProductViewModelService.cs | 17 +++++--- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index d0b401084a..50862b7de1 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -41,6 +41,7 @@ public class ProductViewModelServiceTests private Mock _discountServiceMock; private Mock _enumTranslationServiceMock; + private Mock _groupServiceMock; private Mock _measureServiceMock; private ProductViewModelService _productViewModelService; private Mock> _scopeMock; @@ -54,6 +55,7 @@ public void Setup() { _discountServiceMock = new Mock(); _enumTranslationServiceMock = new Mock(); + _groupServiceMock = new Mock(); _measureServiceMock = new Mock(); _storeServiceMock = new Mock(); _taxCategoryServiceMock = new Mock(); @@ -69,6 +71,7 @@ public void Setup() var currencyServiceMock = new Mock(); currencyServiceMock.Setup(c => c.GetCurrencyById(It.IsAny())).ReturnsAsync((Currency)null); + currencyServiceMock.Setup(c => c.GetAllCurrencies(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); _measureServiceMock.Setup(m => m.GetMeasureWeightById(It.IsAny())).ReturnsAsync((MeasureWeight)null); _measureServiceMock.Setup(m => m.GetMeasureDimensionById(It.IsAny())) .ReturnsAsync((MeasureDimension)null); @@ -96,6 +99,9 @@ public void Setup() _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List()); + _groupServiceMock.Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList()); + _enumTranslationServiceMock .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new SelectList(Enumerable.Empty())); @@ -124,7 +130,7 @@ public void Setup() productLayoutServiceMock.Object, new Mock().Object, contextAccessorMock.Object, - new Mock().Object, + _groupServiceMock.Object, _warehouseServiceMock.Object, deliveryDateServiceMock.Object, _taxCategoryServiceMock.Object, @@ -248,4 +254,36 @@ public async Task PrepareProductListModel_VendorScope_HidesHomepageOptionAndStor Assert.IsFalse(model.AvailableStores.Any(), "Vendor doesn't pick stores - no dropdown should be populated at all."); } + + [TestMethod] + public async Task PrepareTierPriceModel_VendorScope_HidesStoreDropdown() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + _scopeMock.Setup(s => s.ShowStoreSelector).Returns(false); + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productViewModelService.PrepareTierPriceModel(model); + + Assert.IsFalse(model.AvailableStores.Any(), + "Vendor doesn't pick stores - no dropdown should be populated at all, matching the original Vendor service's PrepareTierPriceModel."); + } + + [TestMethod] + public async Task PrepareTierPriceModel_GlobalScope_IncludesStoreDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultStoreId null, ShowStoreSelector true). + _storeServiceMock.Setup(s => s.GetAllStores()).ReturnsAsync(new List { + new() { Id = "store1", Shortcut = "Store 1" } + }); + + var model = new ProductModel.TierPriceModel(); + await _productViewModelService.PrepareTierPriceModel(model); + + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), + "Admin should offer a store dropdown for tier prices."); + } } diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index fddc9246cc..490573d65c 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -143,12 +143,19 @@ public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttr public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) { var storeId = scope.DefaultStoreId ?? ""; - if (string.IsNullOrEmpty(storeId)) - model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var store in (await storeService.GetAllStores()).Where(x => - x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) - model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + //stores - same capability gate as PrepareProductListModel: Vendor's original PrepareTierPriceModel + //never populated a stores dropdown at all (vendors don't pick stores), so this must not run for + //Vendor even though its storeId also resolves to "". + if (scope.ShowStoreSelector) + { + if (string.IsNullOrEmpty(storeId)) + model.AvailableStores.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + + foreach (var store in (await storeService.GetAllStores()).Where(x => + x.Id == storeId || string.IsNullOrWhiteSpace(storeId))) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } //customer groups model.AvailableCustomerGroups.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); From ae5a6d1878e583efbf440840f550c8f115012019 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:16:04 +0200 Subject: [PATCH 047/147] Reconcile remaining ProductViewModelService rows: vendor-scope product search/bulk-edit, drop dead params (ARCH-001 Phase 1 Task 10) - Add DefaultVendorId to IAdminDataScope, implemented across GlobalAdminDataScope/StoreAdminDataScope (null) and VendorProductDataScope (CurrentVendor.Id), mirroring ShowStoreSelector. - PrepareBulkEditProductModel: hard-fix prerequisite for Task 11 - force vendorId: scope.DefaultVendorId onto the underlying SearchProducts call. Previously had no vendor filtering at all. - PrepareProductsModel, PrepareProducts, PrepareProductModel(AddProductModel overload): override vendorId with scope.DefaultVendorId ?? model.SearchVendorId so a vendor host can never widen its search via a client-supplied vendor id, matching Vendor's original unconditional vendorId: CurrentVendor.Id. - PrepareAddProductModel: gate AvailableVendors population behind scope.DefaultVendorId is null (Vendor's original never showed a vendor picker). - OutOfStockNotifications: drop the unused ProductModel model parameter, matching Vendor's original 3-arg shape (resolves Task 9 Step 1 flag). - UpdateProductSpecificationAttributeModel: drop the unused Product product parameter, matching Vendor's original 2-arg shape (resolves Task 9 Step 1 flag); updated BaseProductController and the still-live legacy Admin/Store ProductController call sites. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/ProductController.cs | 2 +- .../Controllers/BaseProductController.cs | 8 ++-- .../Interfaces/IAdminDataScope.cs | 8 ++++ .../Interfaces/IProductViewModelService.cs | 4 +- .../Services/GlobalAdminDataScope.cs | 2 + .../Services/ProductViewModelService.cs | 45 ++++++++++++++----- .../Services/StoreAdminDataScope.cs | 2 + .../Services/VendorProductDataScope.cs | 2 + .../Controllers/ProductController.cs | 2 +- 9 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index ec406619b9..e2c3de9248 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -1229,7 +1229,7 @@ public async Task ProductSpecAttrPopup( if (psa == null) await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); else - await _productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); + await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 2b84760980..0f34ad6ba1 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -1672,10 +1672,8 @@ public async Task ProductSpecAttrPopup( // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original // ProductSpecAttrPopup(POST) had no check at all, letting any vendor add/edit specification // attributes on another vendor's product by posting its id - closed here the same way as the - // GET popup above. Vendor's original call also used a two-arg - // UpdateProductSpecificationAttributeModel(psa, model) overload that does not exist on the - // shared IProductViewModelService; the shared three-arg (product, psa, model) overload - - // already used by Admin/Store - is used here instead. + // GET popup above. UpdateProductSpecificationAttributeModel's unused `product` parameter was + // dropped in ARCH-001 Phase 1 Task 10 to match Vendor's original two-arg (psa, model) shape. if (!await scope.HasAccess(product)) return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); @@ -1683,7 +1681,7 @@ public async Task ProductSpecAttrPopup( if (psa == null) await productViewModelService.InsertProductSpecificationAttributeModel(model, product); else - await productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); + await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); return new JsonResult(""); } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs index d62b6c4420..639de83540 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IAdminDataScope.cs @@ -38,4 +38,12 @@ public interface IAdminDataScope /// flag, deliberately distinct from being null: DefaultStoreId is also /// null for Admin (global, no default store), where the selector should still show. bool ShowStoreSelector { get; } + + /// Vendor id to force onto product search/listing queries, overriding whatever a caller- + /// supplied search model asks for. Null when the host has no vendor concept (Admin: global; Store: + /// store-scoped, not vendor-scoped). Vendor: the current vendor's id - mirrors Vendor's original + /// service always passing vendorId: CurrentVendor.Id into IProductService.SearchProducts/ + /// PrepareProductList regardless of any vendor filter a client-supplied model field might carry, + /// so a vendor can never search or bulk-list another vendor's products. + string? DefaultVendorId { get; } } diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs index 5e6f298762..9d03ebb6df 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/IProductViewModelService.cs @@ -11,7 +11,7 @@ public interface IProductViewModelService Task PrepareProductReviewModel(ProductReviewModel model, ProductReview productReview, bool excludeProperties, bool formatReviewText); - Task OutOfStockNotifications(Product product, ProductModel model, int prevStockQuantity, + Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock); Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, @@ -161,7 +161,7 @@ Task DeleteProductAttributeCombinationTierPrices(Product product, Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, Product product); - Task UpdateProductSpecificationAttributeModel(Product product, ProductSpecificationAttribute psa, + Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, ProductModel.AddProductSpecificationAttributeModel model); Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); diff --git a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs index 9c1d854424..aa6b9647c0 100644 --- a/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/GlobalAdminDataScope.cs @@ -15,4 +15,6 @@ public class GlobalAdminDataScope : IAdminDataScope public string ResourceKeyPrefix => "Admin"; public bool ShowStoreSelector => true; + + public string? DefaultVendorId => null; } diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 490573d65c..8706d44f1e 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -184,7 +184,7 @@ public virtual async Task PrepareProductAttributeValueModel(Product product, model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; } - public virtual async Task OutOfStockNotifications(Product product, ProductModel model, int prevStockQuantity, + public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, List prevMultiWarehouseStock ) { @@ -620,12 +620,16 @@ public virtual async Task PrepareProductListModel() var markedAsNewOnly = model.SearchPublishedId == 4; + // vendorId: scope.DefaultVendorId (when set) overrides whatever the search model asks for - mirrors + // Vendor's original PrepareProductsModel, which always passed vendorId: CurrentVendor.Id regardless + // of any vendor filter on the model. Admin/Store have no DefaultVendorId, so this falls back to + // model.SearchVendorId unchanged for them. var products = (await productService.SearchProducts( categoryIds: categoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, - vendorId: model.SearchVendorId, + vendorId: scope.DefaultVendorId ?? model.SearchVendorId, warehouseId: model.SearchWarehouseId, productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, @@ -685,12 +689,14 @@ public virtual async Task> PrepareProducts(ProductListModel model break; } + // vendorId: same override as PrepareProductsModel above - mirrors Vendor's original PrepareProducts, + // which always passed vendorId: CurrentVendor.Id regardless of the search model. var products = (await productService.SearchProducts( categoryIds: categoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, - vendorId: model.SearchVendorId, + vendorId: scope.DefaultVendorId ?? model.SearchVendorId, warehouseId: model.SearchWarehouseId, productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, @@ -780,7 +786,7 @@ public virtual async Task UpdateProductModel(Product product, ProductMo await UpdatePictureSeoNames(product); //out of stock notifications - await OutOfStockNotifications(product, model, prevStockQuantity, prevMultiWarehouseStock); + await OutOfStockNotifications(product, prevStockQuantity, prevMultiWarehouseStock); //delete an old "download" file (if deleted or updated) if (!string.IsNullOrEmpty(prevDownloadId) && prevDownloadId != product.DownloadId) @@ -840,8 +846,12 @@ public virtual async Task DeleteSelected(IEnumerable selectedIds) public virtual async Task<(IList products, int totalCount)> PrepareProductModel( ProductModel.AddProductModel model, int pageIndex, int pageSize) { + // vendorId: scope.DefaultVendorId (when set) overrides the search model - mirrors Vendor's original + // PrepareProductModel(AddProductModel,...), which always passed vendorId: CurrentVendor.Id (and + // storeId: string.Empty) regardless of the model, so the "add related/similar/bundle/etc. product" + // popups can never surface another vendor's products. var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, - model.SearchCollectionId, model.SearchStoreId, model.SearchVendorId, model.SearchProductTypeId, + model.SearchCollectionId, model.SearchStoreId, scope.DefaultVendorId ?? model.SearchVendorId, model.SearchProductTypeId, model.SearchProductName, pageIndex, pageSize); return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); } @@ -1255,10 +1265,17 @@ public virtual async Task PrepareBulkEditListModel() if (!string.IsNullOrEmpty(model.SearchCategoryId)) searchCategoryIds.Add(model.SearchCategoryId); + // vendorId: scope.DefaultVendorId forces the bulk-edit grid to the current vendor's own products. + // Vendor's original PrepareBulkEditProductModel always passed vendorId: CurrentVendor.Id; this + // shared version previously had no vendor filtering at all - a real gap, since BulkEditListModel + // has no SearchVendorId field for a caller to (mis)supply in the first place, so there is nothing + // to fall back to for Admin/Store, whose scope.DefaultVendorId is null and who intentionally see + // every vendor's products in bulk-edit. var products = (await productService.SearchProducts(categoryIds: searchCategoryIds, brandId: model.SearchBrandId, collectionId: model.SearchCollectionId, storeId: model.SearchStoreId, + vendorId: scope.DefaultVendorId ?? "", productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, keywords: model.SearchProductName, pageIndex: pageIndex - 1, @@ -2476,8 +2493,8 @@ public virtual async Task InsertProductSpecificationAttributeModel( product.ProductSpecificationAttributes.Add(psa); } - public virtual async Task UpdateProductSpecificationAttributeModel(Product product, - ProductSpecificationAttribute psa, ProductModel.AddProductSpecificationAttributeModel model) + public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, + ProductModel.AddProductSpecificationAttributeModel model) { psa = model.ToEntity(psa); await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); @@ -2594,10 +2611,16 @@ protected virtual async Task SaveProductTags(Product product, string[] productTa model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); } - //vendors - model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); - foreach (var v in await vendorService.GetAllVendors(showHidden: true)) - model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + //vendors - only when the host isn't already forced onto a single vendor (scope.DefaultVendorId): + //Vendor's original PrepareAddProductModel never populated a vendor picker at all (matches the + //ShowStoreSelector gate above for the same reason - there's nothing to pick, the search is always + //forced to the current vendor). + if (scope.DefaultVendorId is null) + { + model.AvailableVendors.Add(new SelectListItem { Text = translationService.GetResource("Admin.Common.All"), Value = " " }); + foreach (var v in await vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } //product types model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs index 632fbd17c3..b1956ff0ec 100644 --- a/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/StoreAdminDataScope.cs @@ -41,4 +41,6 @@ public IQueryable ApplyScope(IQueryable query) public string ResourceKeyPrefix => "Admin"; public bool ShowStoreSelector => true; + + public string? DefaultVendorId => null; } diff --git a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs index c916139a4d..250313a6fe 100644 --- a/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs +++ b/src/Web/Grand.Web.AdminShared/Services/VendorProductDataScope.cs @@ -25,4 +25,6 @@ public IQueryable ApplyScope(IQueryable query) public string ResourceKeyPrefix => "Vendor"; public bool ShowStoreSelector => false; + + public string? DefaultVendorId => contextAccessor.WorkContext.CurrentVendor.Id; } diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index a53df58e1b..571c94310e 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -1421,7 +1421,7 @@ public async Task ProductSpecAttrPopup( if (psa == null) await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); else - await _productViewModelService.UpdateProductSpecificationAttributeModel(product, psa, model); + await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); return new JsonResult(""); } From 85ef2bf03ced61cbfc2c08ddf8c0a14671b20618 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:16:09 +0200 Subject: [PATCH 048/147] Add tests for vendor-scoped product search and dead-param drops (ARCH-001 Phase 1 Task 10) Covers PrepareBulkEditProductModel, PrepareProductsModel, PrepareProducts, PrepareProductModel(AddProductModel overload), and PrepareAddProductModel's vendor dropdown gating; updates the two UpdateProductSpecificationAttributeModel call-site assertions in BaseProductControllerTests for the dropped product param. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/BaseProductControllerTests.cs | 6 +- .../Services/ProductViewModelServiceTests.cs | 133 +++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 785128c8d5..c783cacd79 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -2478,7 +2478,7 @@ public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() Assert.IsInstanceOfType(result); _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny(), + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -2500,7 +2500,7 @@ public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_Does s => s.InsertProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny(), + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), It.IsAny()), Times.Never); } @@ -2531,7 +2531,7 @@ public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_U var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(product, psa, model), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); } [TestMethod] diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 50862b7de1..daf3706d34 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -22,6 +22,7 @@ using Grand.Domain.Shipping; using Grand.Domain.Stores; using Grand.Domain.Tax; +using Grand.Domain.Vendors; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Catalog; @@ -43,11 +44,13 @@ public class ProductViewModelServiceTests private Mock _enumTranslationServiceMock; private Mock _groupServiceMock; private Mock _measureServiceMock; + private Mock _productServiceMock; private ProductViewModelService _productViewModelService; private Mock> _scopeMock; private Mock _storeServiceMock; private Mock _taxCategoryServiceMock; private Mock _translationServiceMock; + private Mock _vendorServiceMock; private Mock _warehouseServiceMock; [TestInitialize] @@ -57,9 +60,11 @@ public void Setup() _enumTranslationServiceMock = new Mock(); _groupServiceMock = new Mock(); _measureServiceMock = new Mock(); + _productServiceMock = new Mock(); _storeServiceMock = new Mock(); _taxCategoryServiceMock = new Mock(); _translationServiceMock = new Mock(); + _vendorServiceMock = new Mock(); _warehouseServiceMock = new Mock(); _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); @@ -106,14 +111,15 @@ public void Setup() .Setup(e => e.ToSelectList(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new SelectList(Enumerable.Empty())); - // Default: Admin's Global scope - no default store, homepage option and store dropdown both show. + // Default: Admin's Global scope - no default store/vendor, homepage option and store dropdown both show. _scopeMock = new Mock>(); _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); _scopeMock.Setup(s => s.ShowStoreSelector).Returns(true); + _scopeMock.Setup(s => s.DefaultVendorId).Returns((string)null); _productViewModelService = new ProductViewModelService( - new Mock().Object, + _productServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, @@ -125,7 +131,7 @@ public void Setup() new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, + _vendorServiceMock.Object, _translationServiceMock.Object, productLayoutServiceMock.Object, new Mock().Object, @@ -286,4 +292,125 @@ public async Task PrepareTierPriceModel_GlobalScope_IncludesStoreDropdown() Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "store1"), "Admin should offer a store dropdown for tier prices."); } + + // --- Vendor-scoped product search (ARCH-001 Phase 1 Task 10) ------------------------------------ + + private void SetupSearchProducts() + { + _productServiceMock.Setup(p => p.SearchProducts( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((new PagedList(), new List())); + } + + private void VerifySearchProductsCalledWithVendorId(string expectedVendorId) + { + _productServiceMock.Verify(p => p.SearchProducts( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), expectedVendorId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public async Task PrepareBulkEditProductModel_VendorScope_ForcesVendorFilter() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + await _productViewModelService.PrepareBulkEditProductModel(new BulkEditListModel(), 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareBulkEditProductModel_GlobalScope_DoesNotFilterByVendor() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + SetupSearchProducts(); + + await _productViewModelService.PrepareBulkEditProductModel(new BulkEditListModel(), 1, 10); + + VerifySearchProductsCalledWithVendorId(""); + } + + [TestMethod] + public async Task PrepareProductsModel_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductsModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareProductsModel_GlobalScope_UsesModelVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductsModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor2"); + } + + [TestMethod] + public async Task PrepareProducts_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductListModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProducts(model); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareProductModel_AddProductModel_VendorScope_OverridesModelVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + SetupSearchProducts(); + + var model = new ProductModel.AddRelatedProductModel { SearchVendorId = "vendor2" }; + await _productViewModelService.PrepareProductModel(model, 1, 10); + + VerifySearchProductsCalledWithVendorId("vendor1"); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_VendorScope_HidesVendorDropdown() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + + var model = await _productViewModelService.PrepareRelatedProductModel(); + + Assert.IsFalse(model.AvailableVendors.Any(), + "Vendor's original PrepareAddProductModel never populated a vendor picker - the search is always forced to the current vendor."); + _vendorServiceMock.Verify(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task PrepareRelatedProductModel_GlobalScope_IncludesVendorDropdown() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + _vendorServiceMock.Setup(v => v.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), true)) + .ReturnsAsync(new PagedList { new() { Id = "vendor1", Name = "Vendor 1" } }); + + var model = await _productViewModelService.PrepareRelatedProductModel(); + + Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor1"), + "Admin/Store should keep offering a vendor picker."); + } } From c1eb4b0cc65280c8ff71acf56d43b8e014e76686 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:20:35 +0200 Subject: [PATCH 049/147] Force VendorId on InsertProductModel/UpdateProductModel for vendor scope (ARCH-001 Phase 1 Task 10) AdminShared's shared ProductProfile maps ProductModel.VendorId -> Product.VendorId unconditionally (Admin's edit form legitimately reassigns vendor ownership), unlike Vendor's own AutoMapper profile which ignores VendorId and instead has its service set/preserve it manually. Without forcing scope.DefaultVendorId after mapping: - InsertProductModel would map whatever (usually blank) VendorId a vendor's create form posts, orphaning the new product (VendorProductDataScope.HasAccess gates on VendorId equality, so the vendor who just created it would then be denied access). - UpdateProductModel would let a vendor reassign their own product to a different vendor id via a tampered/mass-assigned VendorId field - a cross-vendor takeover primitive once Vendor is wired onto BaseProductController (Task 11). Confirmed via row 'InsertProductModel, UpdateProductModel, DeleteProduct, DeleteSelected - confirm identical between AdminShared/Vendor already' in the Task 10 brief; DeleteProduct and DeleteSelected verified as no-op rows (DeleteSelected is already ownership-filtered at the BaseProductController layer; DeleteProduct's extra download-cleanup in AdminShared is a harmless superset of Vendor's original). Co-Authored-By: Claude Sonnet 5 --- .../Services/ProductViewModelServiceTests.cs | 60 +++++++++++++++++++ .../Services/ProductViewModelService.cs | 20 +++++++ 2 files changed, 80 insertions(+) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index daf3706d34..367f93b1c4 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -24,7 +24,10 @@ using Grand.Domain.Tax; using Grand.Domain.Vendors; using Grand.Infrastructure; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Services; using Grand.Web.Common.Localization; @@ -56,6 +59,9 @@ public class ProductViewModelServiceTests [TestInitialize] public void Setup() { + var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); + AutoMapperConfig.Init(mapperConfig); + _discountServiceMock = new Mock(); _enumTranslationServiceMock = new Mock(); _groupServiceMock = new Mock(); @@ -413,4 +419,58 @@ public async Task PrepareRelatedProductModel_GlobalScope_IncludesVendorDropdown( Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "vendor1"), "Admin/Store should keep offering a vendor picker."); } + + [TestMethod] + public async Task InsertProductModel_VendorScope_ForcesVendorId() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + var model = new ProductModel { Name = "Test product" }; + + var product = await _productViewModelService.InsertProductModel(model); + + Assert.AreEqual("vendor1", product.VendorId, + "Vendor's original InsertProductModel always set product.VendorId = CurrentVendor.Id - without " + + "this, a vendor-created product would map whatever (usually blank) VendorId the model carries " + + "and end up orphaned, inaccessible to the vendor who just created it."); + } + + [TestMethod] + public async Task InsertProductModel_GlobalScope_DoesNotForceVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + var model = new ProductModel { Name = "Test product", VendorId = "vendor2" }; + + var product = await _productViewModelService.InsertProductModel(model); + + Assert.AreEqual("vendor2", product.VendorId, + "Admin/Store should keep whatever vendor ownership the create form assigns."); + } + + [TestMethod] + public async Task UpdateProductModel_VendorScope_ForcesVendorId_PreventsCrossVendorReassignment() + { + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor1"); + var product = new Product { Id = "p1", VendorId = "vendor1" }; + var model = new ProductModel { Id = "p1", VendorId = "vendor-attacker-supplied" }; + + var result = await _productViewModelService.UpdateProductModel(product, model); + + Assert.AreEqual("vendor1", result.VendorId, + "AdminShared's shared ProductProfile does not ignore ProductModel.VendorId -> Product.VendorId " + + "(Admin's edit form legitimately reassigns vendor ownership); without re-forcing it after mapping, " + + "a vendor could reassign their own product to a different vendor id via a tampered VendorId field."); + } + + [TestMethod] + public async Task UpdateProductModel_GlobalScope_UsesModelVendorId() + { + // Default Setup() scope: Admin's Global scope (DefaultVendorId null). + var product = new Product { Id = "p1", VendorId = "vendor1" }; + var model = new ProductModel { Id = "p1", VendorId = "vendor2" }; + + var result = await _productViewModelService.UpdateProductModel(product, model); + + Assert.AreEqual("vendor2", result.VendorId, + "Admin should keep being able to reassign a product's vendor ownership via the edit form."); + } } diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index 8706d44f1e..d072b167a2 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -712,6 +712,16 @@ public virtual async Task InsertProductModel(ProductModel model) //product var product = model.ToEntity(dateTimeService); + // VendorId: forced onto new products when scope.DefaultVendorId is set - mirrors Vendor's original + // InsertProductModel, which always set product.VendorId = CurrentVendor?.Id explicitly and ignored + // VendorId in its AutoMapper profile for exactly this reason. AdminShared's shared ProductProfile + // does *not* ignore ProductModel.VendorId -> Product.VendorId (Admin's create form legitimately + // assigns vendor ownership), so without this, a vendor host would map whatever (usually blank) + // VendorId the model carries, leaving newly-created products belonging to no vendor at all - + // orphaned and inaccessible to the vendor who just created them once VendorProductDataScope.HasAccess + // gates on VendorId equality. + if (scope.DefaultVendorId is not null) product.VendorId = scope.DefaultVendorId; + //discounts var allDiscounts = await discountService.GetDiscountsQuery(DiscountType.AssignedToSkus, model.StoreId); foreach (Discount discount in allDiscounts) @@ -751,6 +761,16 @@ public virtual async Task UpdateProductModel(Product product, ProductMo //product product = model.ToEntity(product, dateTimeService); product.AutoAddRequiredProducts = model.AutoAddRequiredProducts; + + // VendorId: re-forced to scope.DefaultVendorId (when set) after mapping, not merely left alone - + // AdminShared's shared ProductProfile does not ignore ProductModel.VendorId -> Product.VendorId, so + // without this, mapping a caller-supplied model onto the existing entity would let a vendor + // reassign their own product to a different vendor id via a tampered/mass-assigned VendorId field. + // Vendor's original UpdateProductModel never touched VendorId at all (its own AutoMapper profile + // ignores it), relying on the pre-loaded entity's existing value - forcing it here is strictly + // equivalent for legitimately-owned products (already gated by scope.HasAccess before reaching this + // method) while also closing that mass-assignment gap for Admin/Store's shared profile. + if (scope.DefaultVendorId is not null) product.VendorId = scope.DefaultVendorId; product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); //discounts From 0a1a8e1ce0986f8c784878cacc6558853e24e5e2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:39:53 +0200 Subject: [PATCH 050/147] Plan fix: escalate Task 10 review's per-id ownership filter gap to a Task 11 blocking prerequisite Insert{Related,Similar,Bundle,CrossSell,Recommended}ProductModel don't filter SelectedProductIds by ownership - inert today, becomes a live cross-vendor mapping gap the moment Task 11 wires Vendor onto BaseProductController. Also records Task 10's PrepareBulkEditProductModel prerequisite as satisfied, and adds a risk note for InsertProductPicture's IsDefault behavior change. --- .../2026-08-16-arch001-product-consolidation-phase1.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index 165f4513ca..4604e77e1c 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -1290,7 +1290,11 @@ Expected: no matches (or only local variables inside method bodies that read `sc ## Task 11: Convert the three host `ProductController`s to thin subclasses -**Blocking prerequisite (added after Task 8 row 15's review):** do not subclass Vendor's `ProductController` onto `BaseProductController` until Task 10's `PrepareBulkEditProductModel` row is done and confirmed to preserve vendor-scoped filtering on the bulk-edit grid — see that row's note. Wiring Vendor on first would silently expose every vendor's products in `BulkEditSelect`'s grid to every other vendor. Admin and Store have no equivalent gap and can be subclassed independently of this prerequisite. +**Blocking prerequisite (added after Task 8 row 15's review):** do not subclass Vendor's `ProductController` onto `BaseProductController` until Task 10's `PrepareBulkEditProductModel` row is done and confirmed to preserve vendor-scoped filtering on the bulk-edit grid — see that row's note. Wiring Vendor on first would silently expose every vendor's products in `BulkEditSelect`'s grid to every other vendor. Admin and Store have no equivalent gap and can be subclassed independently of this prerequisite. **Status: satisfied as of Task 10 (`ae5a6d187`)** — `PrepareBulkEditProductModel` now vendor-scopes via `IAdminDataScope.DefaultVendorId`, reviewed and confirmed unbypassable. + +**Second blocking prerequisite (added after Task 10's review, opus):** `InsertRelatedProductModel`/`InsertSimilarProductModel`/`InsertBundleProductModel`/`InsertCrossSellProductModel`/`InsertRecommendedProductModel` in `Grand.Web.AdminShared/Services/ProductViewModelService.cs` do not filter `SelectedProductIds` by ownership before inserting mapping rows — Vendor's original `InsertRelatedProductModel` does (`if (product == null || !HasAccessToProduct(product)) continue;`). This gap is inert today only because Vendor isn't yet subclassed onto `BaseProductController`; it goes live the moment this task wires Vendor in, letting a vendor map another vendor's products into their own related/similar/bundle/cross-sell/recommended lists. Add the same per-id `scope.HasAccess` filter (the pattern already used for `AssociatedProductAddPopup` in Task 8 row 9) to all five `Insert*ProductModel` methods in `ProductViewModelService.cs` as part of this task, before or immediately after wiring Vendor's controller — verify with a test asserting a not-owned id is silently dropped, not inserted. + +**Risk note (added after Task 10's review):** `InsertProductPicture` in AdminShared's service never sets `IsDefault`; Vendor's original sets `IsDefault = product.ProductPictures.Any()` (reads as inverted/likely-buggy in the original, but is nonetheless a behavior change for the Vendor host once wired). Confirm intentionally during this task's Vendor smoke-test rather than discovering it post-merge. **Files:** - Modify (rewrite, shrink): `src/Web/Grand.Web.Admin/Controllers/ProductController.cs` From e9eba6a4b4135b0be8a202f183e9dbd43a22e7cf Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:50:17 +0200 Subject: [PATCH 051/147] Reduce Admin and Store ProductController to thin BaseProductController subclasses (ARCH-001 Phase 1 Task 11) Steps 1-2 of Task 11's plan. Admin and Store's ~2500-line ProductControllers are now ~35-line subclasses of BaseProductController; all behavior lives in the shared base (Tasks 7-8), scoped via IAdminDataScope (Tasks 1-10). Deviation from the plan's own inline code snippet, applied deliberately: the snippet showed the new subclasses without [AuthorizeAdmin]/[AuthorizeStore], [AutoValidateAntiforgeryToken], or [AuthorizeMenu]. BaseProductController extends Grand.Web.Common.Controllers.BaseController directly (it cannot inherit any single host's BaseAdminController/BaseStoreController/ BaseVendorController - each carries a different [Area]/[Authorize*] pair and C# has no multiple inheritance), so those attributes no longer arrive transitively. Verified by reading BaseController itself (only [PasswordExpired]/[CustomerActivity]) and each host's base controller. Following the snippet literally would have shipped Admin's and Store's product management without CSRF protection or authentication/authorization filters. Each subclass now restates its own host's attribute set explicitly. Also (plan Step 2 requirement): exposed TranslationService and Scope as protected members on BaseProductController - primary-constructor parameters aren't visible to derived classes by name in C#. Store's EditWarningCheck override uses both, and its condition was re-derived from the original source (ProductController.cs:184-189 pre-Task-11) rather than the plan's own simplified/inaccurate inline version, which was missing the '.Contains(StaffStoreId)' clause. Vendor's equivalent rewrite (Step 3) is done but held back, uncommitted, per plan Step 5 - it doesn't build against Vendor's current DI registration (still Grand.Web.Vendor.Interfaces.IProductViewModelService) until Task 12. Known, planned consequence (Task 13's explicit responsibility, not fixed here): src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs and src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs now fail to compile - both construct the old controllers with the old, wider constructor. Confirmed via full project builds that this is the only break in each test project. Co-Authored-By: Claude Sonnet 5 --- .../Controllers/ProductController.cs | 2491 +-------------- .../Controllers/BaseProductController.cs | 6 + .../Controllers/ProductController.cs | 2803 +---------------- 3 files changed, 76 insertions(+), 5224 deletions(-) diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index e2c3de9248..d01a1ef2dd 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -1,5 +1,3 @@ -using Grand.Business.Core.Dto; -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; @@ -7,2473 +5,36 @@ using Grand.Business.Core.Interfaces.ExportImport; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; -using Grand.Domain.Permissions; -using Grand.SharedKernel.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseAdminController -{ - #region Constructors - - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion - - #region Methods - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - var (productModels, totalCount) = - await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) - { - return RedirectToAction("Edit", "Product", new { id = product.Id }); - } - - //not found - Warning(_translationService.GetResource("Admin.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel(); - await _productViewModelService.PrepareProductModel(model, null, true, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Admin.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, null, false, true); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - var model = product.ToModel(_dateTimeService); - //model.Ticks = product.UpdatedOnUtc.Ticks; - - await _productViewModelService.PrepareProductModel(model, product, false, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Admin.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - product = await _productViewModelService.UpdateProductModel(product, model); - Success(_translationService.GetResource("Admin.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, product, false, true); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Admin.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteSelected(ICollection selectedIds) - { - if (selectedIds != null) await _productViewModelService.DeleteSelected(selectedIds.ToList()); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - var newProduct = await copyProductService.CopyProduct(originalProduct, - copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var ids = new List(); - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - foreach (var str1 in rangeArray) ids.Add(str1); - - var products = await _productService.GetProductsByIds(ids.ToArray(), true); - for (var i = 0; i <= products.Count - 1; i++) - { - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(); - ViewBag.productIdsInput = productIdsInput; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRelatedProductModel(); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareSimilarProductModel(); - return View(model); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareBundleProductModel(); - return View(model); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareCrossSellProductModel(); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRecommendedProductModel(); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertAssociatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new - { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new - { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new - { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = file.FileName; - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var productrSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes()) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - var model = new OrderListModel { - ProductId = productId - }; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", "", productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelAll(ProductListModel model, - [FromServices] IExportManager exportManager) - { - var products = await _productViewModelService.PrepareProducts(model); - try - { - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelSelected(string selectedIds, - [FromServices] IExportManager exportManager) - { - var products = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - products.AddRange(await _productService.GetProductsByIds(ids, true)); - } - - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - - [PermissionAuthorizeAction(PermissionActionName.Import)] - [HttpPost] - public async Task ImportExcel(IFormFile importexcelfile, - [FromServices] IImportManager importManager) - { - try - { - if (importexcelfile is { Length: > 0 }) - { - await importManager.Import(importexcelfile.OpenReadStream()); - } - else - { - Error(_translationService.GetResource("Admin.Common.UploadFile")); - return RedirectToAction("List"); - } - - Success(_translationService.GetResource("Admin.Catalog.Products.Imported")); - return RedirectToAction("List"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - if (products != null) await _productViewModelService.UpdateBulkEdit(products.ToList()); - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - if (products != null) await _productViewModelService.DeleteBulkEdit(products.ToList()); - return new JsonResult(""); - } - - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string Id) - { - var product = await _productService.GetProductById(productId); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, Id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - var warnings = await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, command.Page - 1, - command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var reservations = await _productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new - { - errors = _translationService.GetResource("Admin.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = 0; - switch ((IntervalUnit)model.IntervalUnit) - { - case IntervalUnit.Minute: - minutesToAdd = model.Interval; - break; - case IntervalUnit.Hour: - minutesToAdd = model.Interval * 60; - break; - case IntervalUnit.Day: - minutesToAdd = model.Interval * 60 * 24; - break; - } - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Admin's DI wiring plus the attributes +// that used to arrive transitively via BaseAdminController - BaseProductController can't inherit any +// single host's base controller (it's shared across Admin/Store/Vendor, each with a different +// [Area]/[Authorize*] pair), so each subclass restates its own host's attribute set explicitly. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 0f34ad6ba1..5250db92da 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -88,6 +88,12 @@ public abstract class BaseProductController( /// Overridden by the Store subclass; no-op everywhere else. protected virtual void EditWarningCheck(Product product) { } + // Exposed for host subclasses (ARCH-001 Phase 1 Task 11): primary-constructor parameters are not + // visible to derived classes by name in C#, but Store's EditWarningCheck override and any other + // host-specific override needs to reference these. + protected ITranslationService TranslationService => translationService; + protected IAdminDataScope Scope => scope; + #region Product list / create / edit / delete public IActionResult Index() => RedirectToAction("List"); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 571c94310e..bc24fa7309 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -1,2773 +1,58 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.ExportImport; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; -using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.SharedKernel.Extensions; -using Grand.Web.AdminShared.Extensions; -using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.AdminShared.Models.Orders; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; -using NPOI.SS.Formula.Functions; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseStoreController +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Store's DI wiring, the EditWarningCheck +// hook, and the attributes that used to arrive transitively via BaseStoreController - +// BaseProductController can't inherit any single host's base controller (it's shared across +// Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), so each subclass restates its +// own host's attribute set explicitly. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope) { - #region Constructors - - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - IContextAccessor contextAccessor, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _contextAccessor = contextAccessor; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } - - #endregion - - #region Fields - - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly IContextAccessor _contextAccessor; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; - - #endregion - - #region Methods - - /// - /// Whether the given product is accessible to this store's staff. Null-safe: a missing product - /// is treated the same as one belonging to another store. Mirrors Grand.Web.Vendor's - /// CheckAccessToProduct - callers decide how to respond (redirect, grid error, JSON error, ...), - /// this only answers the yes/no question. - /// - private bool CanAccessProduct(Product product) - { - return product != null && - product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - } - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var (productModels, totalCount) = await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) - { - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - } - - //not found - Warning(_translationService.GetResource("Admin.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel { - StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId - }; - await _productViewModelService.PrepareProductModel(model, null, true, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Admin.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, null, false, true); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!product.LimitedToStores || (product.LimitedToStores && - product.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) && - product.Stores.Count > 1)) - { - Warning(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - } - else - { - if (!CanAccessProduct(product)) - return RedirectToAction("List"); - } - - var model = product.ToModel(_dateTimeService); - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, product, false, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Admin.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - model.Stores = [_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId]; - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - product = await _productViewModelService.UpdateProductModel(product, model); - Success(_translationService.GetResource("Admin.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - await _productViewModelService.PrepareProductModel(model, product, false, true); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - if (product == null) - //No product found with the specified id - return RedirectToAction("List"); - - if (!CanAccessProduct(product)) - return RedirectToAction("Edit", new { id = product.Id }); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Admin.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - - if (originalProduct.LimitedToStores && !originalProduct.Stores.Contains(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - return RedirectToAction("List"); - - originalProduct.LimitedToStores = true; - originalProduct.Stores.Clear(); - originalProduct.Stores.Add(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - - var newProduct = await copyProductService.CopyProduct(originalProduct, copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var ids = new List(); - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - foreach (var str1 in rangeArray) ids.Add(str1); - - var products = await _productService.GetProductsByIds(ids.ToArray(), true); - for (var i = 0; i <= products.Count - 1; i++) - { - if (!CanAccessProduct(products[i])) - continue; - - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRelatedProductModel(); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId1); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareSimilarProductModel(); - return View(model); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductBundleId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductBundleId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareBundleProductModel(); - return View(model); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareCrossSellProductModel(); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareRecommendedProductModel(); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - if (!CanAccessProduct(associatedProduct)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - var parentProduct = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(parentProduct)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - //InsertAssociatedProductModel reparents each selected product (writes ParentGroupedProductId on it), - //so every selected id must also belong to the current store, not just the parent. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await _productService.GetProductById(id); - if (CanAccessProduct(selected)) - validIds.Add(id); - } - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Any()) await _productViewModelService.InsertAssociatedProductModel(model); - } - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new - { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new - { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new - { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - if (!CanAccessProduct(product)) - return Json(new - { - success = false, - message = "Access denied - staff permissions" - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = file.FileName; - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = (await specificationAttributeService.GetSpecificationAttributeById(attributeId)).SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productrSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = new OrderListModel { - ProductId = productId - }; - - model.StoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", storeId, productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - var validProducts = await FilterValidProductsForStore(products); - - if (validProducts.Any()) - await _productViewModelService.UpdateBulkEdit(validProducts); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - var validProducts = await FilterValidProductsForStore(products); - - if (validProducts.Any()) - await _productViewModelService.DeleteBulkEdit(validProducts.ToList()); - - return new JsonResult(""); - } - - - /// - /// Filters product models to include only valid products accessible to the current store - /// - /// Collection of product models to filter - /// List of product models that are accessible to the current store - private async Task> FilterValidProductsForStore(IEnumerable products) - { - var validProducts = new List(); - - foreach (var pModel in products) - { - if (string.IsNullOrEmpty(pModel.Id)) - continue; - - var product = await _productService.GetProductById(pModel.Id); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - continue; - - validProducts.Add(pModel); - } - - return validProducts; - } - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return RedirectToAction("List", "Product"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return RedirectToAction("List", "Product"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (!CanAccessProduct(product)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - model.SearchStoreId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - var (products, totalCount) = await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - if (!CanAccessProduct(associatedProduct)) - throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string Id) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, Id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var warnings = await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, command.Page - 1, - command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var reservations = await _productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new - { - errors = _translationService.GetResource("Admin.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = 0; - switch ((IntervalUnit)model.IntervalUnit) - { - case IntervalUnit.Minute: - minutesToAdd = model.Interval; - break; - case IntervalUnit.Hour: - minutesToAdd = model.Interval * 60; - break; - case IntervalUnit.Day: - minutesToAdd = model.Interval * 60 * 24; - break; - } - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new { errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!CanAccessProduct(product)) - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Permissions") }); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult { Errors = _translationService.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file + // Re-derived from the original Store ProductController.Edit(GET) (pre-Task-11: + // src/Web/Grand.Web.Store/Controllers/ProductController.cs:184-189), not retyped from memory - the + // condition is unusual (warns when NOT limited to stores at all, or when limited AND the staff + // member's store is one of several) and easy to get backwards. Scope.DefaultStoreId is exactly + // StaffStoreId for Store (StoreAdminDataScope.DefaultStoreId => CurrentCustomer.StaffStoreId), so it + // stands in for the original's direct IContextAccessor access without reintroducing that dependency, + // which Tasks 7/8 deliberately eliminated from this call path. + protected override void EditWarningCheck(Product product) + { + if (!product.LimitedToStores || + (product.LimitedToStores && + product.Stores.Contains(Scope.DefaultStoreId) && + product.Stores.Count > 1)) + Warning(TranslationService.GetResource("Admin.Catalog.Products.Permissions")); + } +} From 2bc3309797f4950454ceeaac56dc4186911de757 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:56:49 +0200 Subject: [PATCH 052/147] Filter selected product ids by ownership in Insert*ProductModel (ARCH-001 Phase 1 Task 11) Second blocking prerequisite from Task 10's review: InsertRelatedProductModel/InsertSimilarProductModel/InsertBundleProductModel/InsertCrossSellProductModel/InsertRecommendedProductModel now skip any selected id that fails scope.HasAccess, mirroring the pattern already used in BaseProductController.AssociatedProductAddPopup(POST) (Task 8 row 9). Inert for Admin/Store (scope.HasAccess is a no-op/store-ACL check there); closes a cross-vendor mapping gap that would otherwise go live the moment Vendor is wired onto BaseProductController. Also documents the decision to leave InsertProductPicture's IsDefault unset rather than port Vendor's original (inverted-looking) Any() logic. 5 new tests assert a not-owned selected id is silently dropped, not inserted. --- .../Services/ProductViewModelServiceTests.cs | 120 +++++++++++++++++ .../Services/ProductViewModelService.cs | 126 ++++++++++-------- 2 files changed, 190 insertions(+), 56 deletions(-) diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs index 367f93b1c4..250255df36 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs @@ -473,4 +473,124 @@ public async Task UpdateProductModel_GlobalScope_UsesModelVendorId() Assert.AreEqual("vendor2", result.VendorId, "Admin should keep being able to reassign a product's vendor ownership via the edit form."); } + + // --- Per-id ownership filter on Insert*ProductModel (ARCH-001 Phase 1 Task 11) ------------------ + // Mirrors Vendor's original InsertRelatedProductModel ("if (product == null || + // !HasAccessToProduct(product)) continue;") and the pattern already used for the selected-ids loop + // in BaseProductController.AssociatedProductAddPopup(POST) (Task 8). This gap was inert while Vendor + // wasn't yet subclassed onto BaseProductController; wiring Vendor in (Task 11) makes it live. + + private void SetupOwnershipForInsertTests(Product ownedProduct, Product notOwnedProduct) + { + _productServiceMock.Setup(p => p.GetProductById(ownedProduct.Id, It.IsAny())).ReturnsAsync(ownedProduct); + _productServiceMock.Setup(p => p.GetProductById(notOwnedProduct.Id, It.IsAny())).ReturnsAsync(notOwnedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(notOwnedProduct)).ReturnsAsync(false); + } + + [TestMethod] + public async Task InsertRelatedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRelatedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertRelatedProductModel(model); + + Assert.IsTrue(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.RelatedProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertRelatedProduct(It.Is(r => r.ProductId2 == "not-owned"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InsertSimilarProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddSimilarProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertSimilarProductModel(model); + + Assert.IsTrue(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.SimilarProducts.Any(x => x.ProductId2 == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertSimilarProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never); + } + + [TestMethod] + public async Task InsertBundleProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddBundleProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertBundleProductModel(model); + + Assert.IsTrue(mainProduct.BundleProducts.Any(x => x.ProductId == "owned"), + "The owned selected product should be inserted."); + Assert.IsFalse(mainProduct.BundleProducts.Any(x => x.ProductId == "not-owned"), + "A not-owned selected product must be silently dropped, not inserted."); + _productServiceMock.Verify(p => p.InsertBundleProduct(It.Is(r => r.ProductId == "not-owned"), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task InsertCrossSellProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddCrossSellProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertCrossSellProductModel(model); + + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "owned")), Times.Once); + _productServiceMock.Verify(p => p.InsertCrossSellProduct(It.Is(r => r.ProductId2 == "not-owned")), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } + + [TestMethod] + public async Task InsertRecommendedProductModel_DropsNotOwnedSelectedId() + { + var mainProduct = new Product { Id = "main" }; + var owned = new Product { Id = "owned" }; + var notOwned = new Product { Id = "not-owned" }; + _productServiceMock.Setup(p => p.GetProductById("main", It.IsAny())).ReturnsAsync(mainProduct); + SetupOwnershipForInsertTests(owned, notOwned); + + var model = new ProductModel.AddRecommendedProductModel { + ProductId = "main", + SelectedProductIds = ["owned", "not-owned"] + }; + await _productViewModelService.InsertRecommendedProductModel(model); + + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "owned"), Times.Once); + _productServiceMock.Verify(p => p.InsertRecommendedProduct("main", "not-owned"), Times.Never, + "A not-owned selected product must be silently dropped, not inserted."); + } } diff --git a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs index d072b167a2..7006a2c8fd 100644 --- a/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/ProductViewModelService.cs @@ -1000,20 +1000,22 @@ public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProdu foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingRelatedProducts = productId1.RelatedProducts; - if (model.ProductId != id) - if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) - { - var related = new RelatedProduct { - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.RelatedProducts.Add(related); - await productService.InsertRelatedProduct(related, model.ProductId); - } - } + // scope.HasAccess: same per-id ownership filter as BaseProductController's + // AssociatedProductAddPopup(POST) selected-ids loop - without it a vendor could map another + // vendor's products into their own related-products list (ARCH-001 Phase 1, Task 11). + if (product == null || !await scope.HasAccess(product)) continue; + + var existingRelatedProducts = productId1.RelatedProducts; + if (model.ProductId != id) + if (!existingRelatedProducts.Any(x => x.ProductId2 == id)) + { + var related = new RelatedProduct { + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.RelatedProducts.Add(related); + await productService.InsertRelatedProduct(related, model.ProductId); + } } } @@ -1049,21 +1051,21 @@ public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProdu foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingSimilarProducts = productId1.SimilarProducts; - if (model.ProductId != id) - if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) - { - var similar = new SimilarProduct { - ProductId1 = model.ProductId, - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.SimilarProducts.Add(similar); - await productService.InsertSimilarProduct(similar); - } - } + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingSimilarProducts = productId1.SimilarProducts; + if (model.ProductId != id) + if (!existingSimilarProducts.Any(x => x.ProductId2 == id)) + { + var similar = new SimilarProduct { + ProductId1 = model.ProductId, + ProductId2 = id, + DisplayOrder = 1 + }; + productId1.SimilarProducts.Add(similar); + await productService.InsertSimilarProduct(similar); + } } } @@ -1101,21 +1103,21 @@ public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProduct foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - { - var existingBundleProducts = productId1.BundleProducts; - if (model.ProductId != id) - if (!existingBundleProducts.Any(x => x.ProductId == id)) - { - var bundle = new BundleProduct { - ProductId = id, - DisplayOrder = 1, - Quantity = 1 - }; - productId1.BundleProducts.Add(bundle); - await productService.InsertBundleProduct(bundle, model.ProductId); - } - } + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + var existingBundleProducts = productId1.BundleProducts; + if (model.ProductId != id) + if (!existingBundleProducts.Any(x => x.ProductId == id)) + { + var bundle = new BundleProduct { + ProductId = id, + DisplayOrder = 1, + Quantity = 1 + }; + productId1.BundleProducts.Add(bundle); + await productService.InsertBundleProduct(bundle, model.ProductId); + } } } @@ -1152,14 +1154,16 @@ public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellP foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertCrossSellProduct( - new CrossSellProduct { - ProductId1 = model.ProductId, - ProductId2 = id - }); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!crossSellProduct.CrossSellProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertCrossSellProduct( + new CrossSellProduct { + ProductId1 = model.ProductId, + ProductId2 = id + }); } } @@ -1178,10 +1182,12 @@ public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommen foreach (var id in model.SelectedProductIds) { var product = await productService.GetProductById(id); - if (product != null) - if (!mainproduct.RecommendedProduct.Any(x => x == id)) - if (model.ProductId != id) - await productService.InsertRecommendedProduct(model.ProductId, id); + // scope.HasAccess: see InsertRelatedProductModel above. + if (product == null || !await scope.HasAccess(product)) continue; + + if (!mainproduct.RecommendedProduct.Any(x => x == id)) + if (model.ProductId != id) + await productService.InsertRecommendedProduct(model.ProductId, id); } } @@ -2398,6 +2404,14 @@ public virtual async Task InsertProductPicture(Product product, Picture picture, if (product.ProductPictures.Any(x => x.PictureId == picture.Id)) return; + // IsDefault: deliberately left unset here (defaults to false), matching Admin/Store's original + // InsertProductPicture. Vendor's original copy set `IsDefault = product.ProductPictures.Any()` - + // true only once a picture already exists, i.e. false on the very first upload and true on every + // one after that. That reads as inverted (the first picture is normally the one that should + // default to true) rather than as an intentional feature Admin/Store were missing, so it is not + // being ported here. This is a documented behavior decision (ARCH-001 Phase 1, Task 11), not an + // oversight - flag for product-team review if Vendor's admins report the default-picture picker + // behaving differently than before. var productPicture = new ProductPicture { PictureId = picture.Id, DisplayOrder = displayOrder From 86f50b49a118c764460ff19d70b11bc3d1458017 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 12:56:55 +0200 Subject: [PATCH 053/147] Delete stale Admin/Store ProductControllerTests.cs, superseded by thin subclass + BaseProductControllerTests (ARCH-001 Phase 1 Task 11) Both files constructed the old ~2500-line ProductController with its old constructor signature and stopped compiling once Task 11 replaced Admin's and Store's controllers with thin BaseProductController subclasses. Their coverage is subsumed by BaseProductControllerTests.cs (verified as a strict superset of the three original controllers' public surface in Task 8's Step 3). Full characterization-test consolidation/trim is Task 13's job; this is the minimal removal needed to keep Admin.Tests and Store.Tests building green in the interim - Store's file had zero EditWarningCheck-specific coverage to lose. --- .../Controllers/ProductControllerTests.cs | 83 -- .../Controllers/ProductControllerTests.cs | 1290 ----------------- 2 files changed, 1373 deletions(-) delete mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs delete mode 100644 src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs deleted file mode 100644 index 89e99b7f71..0000000000 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Web.Admin.Controllers; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.Common.Localization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; - -namespace Grand.Web.Admin.Tests.Controllers; - -// Characterization tests locking down the baseline for the planned ProductController consolidation: -// unlike Store/Vendor, Admin performs no ownership/scope check at all - any product can be deleted by -// any admin. If a shared base class is introduced later, this must stay true for Admin. -[TestClass] -public class ProductControllerTests -{ - private ProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - - [TestInitialize] - public void Setup() - { - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _controller = new ProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - new Mock().Object, - _translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object); - - var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_AnyExistingProduct_DeletesWithoutOwnershipCheck() - { - // No IContextAccessor is even injected here - unlike Store/Vendor, Admin has no notion of - // "not your product". A product limited to a store it has no relation to must still delete. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add("some-other-store"); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } -} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs deleted file mode 100644 index 11558b0bae..0000000000 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs +++ /dev/null @@ -1,1290 +0,0 @@ -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Domain.Customers; -using Grand.Domain.Localization; -using Grand.Domain.Permissions; -using Grand.Infrastructure; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Localization; -using Grand.Web.Store.Controllers; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; - -namespace Grand.Web.Store.Tests.Controllers; - -// Characterization tests for the store-scoping checks in ProductController, ahead of the planned -// consolidation of the near-duplicate ProductController copies in Grand.Web.Admin / Grand.Web.Store / -// Grand.Web.Vendor. Note the current redirect target on denial differs from Vendor's equivalent -// (Edit id= here vs List there) - that asymmetry must survive any refactor, or be called out as an -// intentional behavior change. -[TestClass] -public class ProductControllerTests -{ - private const string StaffStoreId = "store-1"; - private const string OtherStoreId = "store-2"; - - private ProductController _controller; - private Mock _permissionServiceMock; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - - [TestInitialize] - public void Setup() - { - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _permissionServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); - var contextAccessorMock = new Mock(); - contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); - - var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); - - _controller = new ProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - contextAccessorMock.Object, - languageServiceMock.Object, - _translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - _permissionServiceMock.Object, - new Mock().Object); - - var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } - - // --- Shared helpers for the CanAccessProduct denial tests below ------------------------------- - // Every action denies access via the same rule (AclMappingExtension.AccessToEntityByStore: an - // explicit single foreign store beats the staff member's store). Centralizing the "denied product" - // shape and the per-response-type assertions keeps each of the ~60 call sites below to a few lines, - // matching the mechanical nature of the CanAccessProduct extraction itself. - - private static Product ForeignProduct(string id = "denied") - { - var product = new Product { Id = id, LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - return product; - } - - private void MockAnyProductLookupAsForeign() - { - _productServiceMock.Setup(p => p.GetProductById(It.IsAny(), It.IsAny())) - .ReturnsAsync(ForeignProduct()); - } - - private void MockSkuLookupAsForeign() - { - _productServiceMock.Setup(p => p.GetProductBySku(It.IsAny())).ReturnsAsync(ForeignProduct()); - } - - private static void AssertKendoGridPermissionError(IActionResult result) - { - var json = result as JsonResult; - Assert.IsNotNull(json, "expected a JsonResult"); - var data = json.Value as DataSourceResult; - Assert.IsNotNull(data, "expected a DataSourceResult"); - Assert.AreEqual("resource", data.Errors); - } - - private static void AssertContentPermissionError(IActionResult result) - { - var content = result as ContentResult; - Assert.IsNotNull(content, "expected a ContentResult"); - Assert.AreEqual("resource", content.Content); - } - - private static void AssertJsonErrorsPermissionError(IActionResult result) - { - var json = result as JsonResult; - Assert.IsNotNull(json, "expected a JsonResult"); - var errorsProp = json.Value?.GetType().GetProperty("errors"); - Assert.IsNotNull(errorsProp, "expected an anonymous object with an 'errors' property"); - Assert.AreEqual("resource", errorsProp.GetValue(json.Value)); - } - - private static void AssertRedirectToProductList(IActionResult result) - { - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect, "expected a RedirectToActionResult"); - Assert.AreEqual("List", redirect.ActionName); - Assert.AreEqual("Product", redirect.ControllerName); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOutsideStaffStore_RedirectsToEditWithoutDeleting() - { - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - // Unlike Vendor, denial here redirects back to Edit rather than List. - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("p1", redirect.RouteValues["id"]); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductInStaffStore_DeletesAndRedirectsToList() - { - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } - - [TestMethod] - public async Task Delete_ProductNotLimitedToAnyStore_IsDenied() - { - // Counter-intuitive but current behavior: AccessToEntityByStore only grants access when - // LimitedToStores is true AND the product belongs to exactly one store (this one). A - // "global" (LimitedToStores=false) product is therefore NOT deletable by store staff - - // see AclMappingExtension.AccessToEntityByStore. A refactor must not silently "fix" this. - var product = new Product { Id = "p1", LimitedToStores = false }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductInMultipleStoresIncludingStaffStore_IsDenied() - { - // Same source: Stores.Count == 1 is required, so a product shared across stores is denied - // even to a staff member of one of those stores. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Edit(new ProductModel { Id = "missing" }, continueEditing: false); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductOutsideStaffStore_RedirectsToEditWithoutUpdating() - { - // Same check, same "Edit" redirect target as Delete - but note this is a *different* check - // from Edit(GET), which additionally allows a multi-store product through with a warning. - // Do not fold this into a helper shared with Edit(GET). - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("p1", redirect.RouteValues["id"]); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task EditGet_ProductSharedAcrossMultipleStoresIncludingStaffStore_ShowsFormWithWarning() - { - // Edit(GET)'s permissive branch: a product limited to more than one store, one of which is - // this staff member's store, is NOT denied here - it is shown with a warning instead. This is - // the one path that must stay outside any shared "authorize or redirect" helper. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(StaffStoreId); - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), product, false, false), Times.Once); - } - - [TestMethod] - public async Task EditGet_ProductInSingleOtherStore_RedirectsToList() - { - // The strict branch of Edit(GET) - a product limited to exactly one store that isn't this - // staff member's - as opposed to the permissive multi-store branch tested above. - var product = new Product { Id = "p1", LimitedToStores = true }; - product.Stores.Add(OtherStoreId); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task GoToSku_ProductNotAccessible_RedirectsToEditWithoutExposingIt() - { - MockSkuLookupAsForeign(); - - var result = await _controller.GoToSku(new ProductListModel { GoDirectlyToSku = "sku1" }); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("Edit", redirect.ActionName); - Assert.AreEqual("denied", redirect.RouteValues["id"]); - } - - [TestMethod] - public async Task LoadProductFriendlyNames_SkipsNamesOfProductsNotAccessible() - { - var owned = new Product { Id = "owned", Name = "Owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - var foreign = ForeignProduct("foreign"); - foreign.Name = "Foreign"; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, true)) - .ReturnsAsync(new List { owned, foreign }); - - var result = await _controller.LoadProductFriendlyNames("owned,foreign"); - - // Note: the trailing ", " is current behavior, not intentional - the separator is appended - // based on loop position ("not the last id"), not on whether a name was actually appended for - // the *previous* id. Preserved as-is; not this refactor's concern to fix. - var json = result as JsonResult; - Assert.IsNotNull(json); - var text = json.Value.GetType().GetProperty("Text")?.GetValue(json.Value) as string; - Assert.AreEqual("Owned, ", text); - } - - // --- Product categories --------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCategoryList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCategoryInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryUpdate(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCategoryDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCategoryDelete(new ProductModel.ProductCategoryModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- Product collections ---------------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionInsert(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionUpdate(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductCollectionDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductCollectionDelete(new ProductModel.ProductCollectionModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Related products -------------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductUpdate(new ProductModel.RelatedProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductDelete(new ProductModel.RelatedProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RelatedProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RelatedProductAddPopup(new ProductModel.AddRelatedProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - // --- Similar products --------------------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductUpdate(new ProductModel.SimilarProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductDelete(new ProductModel.SimilarProductModel { ProductId1 = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task SimilarProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.SimilarProductAddPopup(new ProductModel.AddSimilarProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Bundle products ------------------------------------------------------------------------------ - - [TestMethod] - public async Task BundleProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductUpdate(new ProductModel.BundleProductModel { ProductBundleId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductDelete(new ProductModel.BundleProductModel { ProductBundleId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task BundleProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BundleProductAddPopup(new ProductModel.AddBundleProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Cross-sell products -------------------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task CrossSellProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductDelete(new ProductModel.CrossSellProductModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task CrossSellProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.CrossSellProductAddPopup(new ProductModel.AddCrossSellProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Recommended products ------------------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RecommendedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductDelete(new ProductModel.RecommendedProductModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task RecommendedProductAddPopup_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.RecommendedProductAddPopup(new ProductModel.AddRecommendedProductModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Associated products ---------------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AssociatedProductUpdate_AssociatedProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductUpdate(new ProductModel.AssociatedProductModel { Id = "p1" }); - - AssertKendoGridPermissionError(result); - _productServiceMock.Verify(s => s.UpdateAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductDelete(new ProductModel.AssociatedProductModel { Id = "p1" }); - - AssertKendoGridPermissionError(result); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopup_ParentProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AssociatedProductAddPopup(new ProductModel.AddAssociatedProductModel { - ProductId = "p1" - }); - - AssertContentPermissionError(result); - _productViewModelServiceMock.Verify( - s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopup_ParentOwnedButCandidateNotAccessible_ExcludesCandidate() - { - // The parent product is the vendor's own, but one selected candidate belongs to another - // store - AssociatedProductAddPopup filters SelectedProductIds down to only the accessible - // ones (the positive `CanAccessProduct(selected)` form) before calling InsertAssociatedProductModel. - var parent = new Product { Id = "parent", LimitedToStores = true }; - parent.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("parent", It.IsAny())).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var model = new ProductModel.AddAssociatedProductModel { - ProductId = "parent", - SelectedProductIds = ["foreign"] - }; - - var result = await _controller.AssociatedProductAddPopup(model); - - AssertSuccessContent(result); - _productViewModelServiceMock.Verify( - s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - private static void AssertSuccessContent(IActionResult result) - { - var content = result as ContentResult; - Assert.IsNotNull(content, "expected a ContentResult (success path, not the permission-denied one)"); - Assert.AreEqual("", content.Content); - } - - // --- Product pictures -------------------------------------------------------------------------- - // ProductPictureAdd is deliberately not covered here: reaching its CanAccessProduct check requires - // a non-empty IFormFileCollection and a prior Pictures-permission check, disproportionate setup for - // what is otherwise the same one-line condition covered everywhere else in this file. - - [TestMethod] - public async Task ProductPictureList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPictureList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ProductNotAccessible_Throws() - { - // Unlike its GET counterpart, the POST handler throws instead of returning an error response. - MockAnyProductLookupAsForeign(); - - try - { - await _controller.ProductPicturePopup(new ProductModel.ProductPictureModel { ProductId = "p1" }); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - [TestMethod] - public async Task ProductPictureDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPictureDelete(new ProductModel.ProductPictureModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Product specification attributes --------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrPopup( - new Mock().Object, "p1", null); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrPopup( - new Mock().Object, - new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductSpecAttrDelete(new ProductSpecificationAttributeModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - // --- Purchased with orders / Reviews ------------------------------------------------------------ - - [TestMethod] - public async Task PurchasedWithOrders_ProductNotAccessible_ReturnsKendoGridError() - { - _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); - MockAnyProductLookupAsForeign(); - - var result = await _controller.PurchasedWithOrders(new DataSourceRequest(), "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task Reviews_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.Reviews(new DataSourceRequest(), "p1", new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - // --- Bulk editing -------------------------------------------------------------------------------- - - [TestMethod] - public async Task BulkEditDelete_FiltersOutProductsNotAccessible() - { - var owned = new Product { Id = "owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("owned", It.IsAny())).ReturnsAsync(owned); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var models = new List { - new() { Id = "owned" }, - new() { Id = "foreign" } - }; - - await _controller.BulkEditDelete(models); - - _productViewModelServiceMock.Verify(s => s.DeleteBulkEdit( - It.Is>(list => list.Count == 1 && list[0].Id == "owned")), Times.Once); - } - - [TestMethod] - public async Task BulkEditUpdate_FiltersOutProductsNotAccessible() - { - var owned = new Product { Id = "owned", LimitedToStores = true }; - owned.Stores.Add(StaffStoreId); - _productServiceMock.Setup(p => p.GetProductById("owned", It.IsAny())).ReturnsAsync(owned); - _productServiceMock.Setup(p => p.GetProductById("foreign", It.IsAny())).ReturnsAsync(ForeignProduct("foreign")); - - var models = new List { - new() { Id = "owned" }, - new() { Id = "foreign" } - }; - - await _controller.BulkEditUpdate(models); - - _productViewModelServiceMock.Verify(s => s.UpdateBulkEdit( - It.Is>(list => list.Count() == 1 && list.First().Id == "owned")), - Times.Once); - } - - // --- Product currency price ------------------------------------------------------------------------ - - [TestMethod] - public async Task ProductPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceInsert_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceInsert(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceUpdate_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceUpdate(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductPriceDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductPriceDelete(new ProductModel.ProductPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Tier prices ----------------------------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceCreatePopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceCreatePopup(new ProductModel.TierPriceModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceEditPopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceEditPopup("p1", new ProductModel.TierPriceModel()); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task TierPriceDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.TierPriceDelete(new ProductModel.TierPriceDeleteModel("t1", "p1")); - - AssertKendoGridPermissionError(result); - } - - // --- Product attributes ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeMappingList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingPopupGet_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeMappingPopup( - new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ProductNotAccessible_ReturnsKendoGridError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValidationRulesPopup("id1", "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeConditionPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeConditionPopupPost_ProductNotAccessible_ReturnsContentError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeConditionPopup( - new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task EditAttributeValues_ProductNotAccessible_ReturnsContentError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeMappings.Add(new ProductAttributeMapping { Id = "pam1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.EditAttributeValues("pam1", "p1", new Mock().Object); - - AssertContentPermissionError(result); - } - - // --- Product attribute values ------------------------------------------------------------------ - - [TestMethod] - public async Task ProductAttributeValueList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new DataSourceRequest()); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopupPost_ProductNotAccessible_RedirectsToProductList() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueCreatePopup( - new ProductModel.ProductAttributeValueModel { ProductId = "p1" }); - - AssertRedirectToProductList(result); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueEditPopup("val1", "p1", "pam1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopupPost_ProductNotAccessible_RedirectsToProductList() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeValueEditPopup("p1", - new ProductModel.ProductAttributeValueModel()); - - AssertRedirectToProductList(result); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ProductNotAccessible_Throws() - { - var foreign = ForeignProduct(); - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(new ProductAttributeValue { Id = "val1" }); - foreign.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - try - { - await _controller.ProductAttributeValueDelete("val1", "pam1", "p1", - new Mock().Object); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_AssociatedProductNotAccessible_Throws() - { - MockAnyProductLookupAsForeign(); - - try - { - await _controller.AssociateProductToAttributeValuePopup( - new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel { - AssociatedToProductId = "p1" - }); - Assert.Fail("expected an ArgumentException"); - } - catch (ArgumentException) - { - // expected - } - } - - // --- Product attribute combinations --------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ProductNotAccessible_ReturnsKendoGridError() - { - var foreign = ForeignProduct(); - foreign.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", It.IsAny())).ReturnsAsync(foreign); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", - new Mock().Object); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AttributeCombinationPopupGet_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.AttributeCombinationPopup("p1", new ProductAttributeCombinationModel { ProductId = "p1" }); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - AssertContentPermissionError(result); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ProductNotAccessible_ReturnsContentError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "t1"); - - AssertContentPermissionError(result); - } - - // --- Reservation ---------------------------------------------------------------------------------- - - [TestMethod] - public async Task ListReservations_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ListReservations(new DataSourceRequest(), "p1"); - - AssertKendoGridPermissionError(result); - } - - [TestMethod] - public async Task GenerateCalendar_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.GenerateCalendar("p1", new ProductModel.GenerateCalendarModel()); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ClearCalendar_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearCalendar("p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ClearOld_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ClearOld("p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task ProductReservationDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ProductReservationDelete(new ProductModel.ReservationModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } - - // --- Bids ----------------------------------------------------------------------------------------- - - [TestMethod] - public async Task ListBids_ProductNotAccessible_ReturnsJsonErrors() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.ListBids(new DataSourceRequest(), "p1"); - - AssertJsonErrorsPermissionError(result); - } - - [TestMethod] - public async Task BidDelete_ProductNotAccessible_ReturnsKendoGridError() - { - MockAnyProductLookupAsForeign(); - - var result = await _controller.BidDelete(new ProductModel.BidModel { ProductId = "p1" }); - - AssertKendoGridPermissionError(result); - } -} From 6787f53fc0c1785416fe370d67b35e8623a5a44a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 16:19:02 +0200 Subject: [PATCH 054/147] Task 11 fix round 1: close review findings (attribute regression tests, EditWarningCheck coverage, unused usings) Review (opus) PASS on spec compliance and code quality, with 5 non-blocking findings. Addresses the 3 Medium findings that were cheap and actionable now: - Add ProductControllerAttributesTests to Admin.Tests and Store.Tests: reflection-based regression lock asserting the thin subclasses carry [AuthorizeAdmin]/[AuthorizeStore], [AutoValidateAntiforgeryToken], [Area], [AuthorizeMenu], and the inherited [PermissionAuthorize(Products)] - the exact attribute set whose omission in the plan's own example code was caught (uncommitted, undetected) during this task. - Add Store.Tests/Controllers/ProductControllerTests.cs with 3 EditWarningCheck cases (not-limited, limited-to-multiple-including-staff-store, limited-to-staff-store-only) - the one piece of hand-ported logic this task newly wrote, previously covered by zero tests after the stale file's deletion. - Removed unused Grand.Business.Core.Interfaces.ExportImport/Storage usings from Admin's ProductController.cs (also present in Store/Vendor's; those two still need Extensions for their own Constants class, left alone). Findings 3-5 (Vendor's uncommitted controller missing its 6 documented override hooks; commit 2bc330979's message overstating the filter's inertness for Store; DefaultStoreId/DefaultVendorId force-point asymmetry) handled separately: finding 3 fixed directly in Vendor's still-uncommitted working-tree file (adds AssociatedProductVendorId and the 5 Invalid*ProductAddPopupResult overrides, per each hook's own doc comment); findings 4-5 parked in the ledger as accurate-but-low-value-now notes. --- .../ProductControllerAttributesTests.cs | 39 ++++++ .../ProductControllerAttributesTests.cs | 39 ++++++ .../Controllers/ProductControllerTests.cs | 112 ++++++++++++++++++ .../Controllers/ProductController.cs | 2 - 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs create mode 100644 src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs create mode 100644 src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs new file mode 100644 index 0000000000..1c6e16d51b --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs @@ -0,0 +1,39 @@ +using Grand.Domain.Permissions; +using Grand.Web.Admin.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeAdmin]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Admin's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +[TestClass] +public class ProductControllerAttributesTests +{ + [TestMethod] + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + { + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), "Missing [AuthorizeAdmin]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs new file mode 100644 index 0000000000..657d4e5085 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs @@ -0,0 +1,39 @@ +using Grand.Domain.Permissions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Store.Tests.Controllers; + +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeStore]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Store's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +[TestClass] +public class ProductControllerAttributesTests +{ + [TestMethod] + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + { + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), "Missing [AuthorizeStore]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs new file mode 100644 index 0000000000..9d3b20680e --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs @@ -0,0 +1,112 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Catalog; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Controllers; + +// Characterization test for ProductController.EditWarningCheck (ARCH-001 Phase 1 Task 11). This is the +// one piece of behavior newly hand-written for this task (re-derived from the original pre-migration +// Edit(GET) action's if/else, not copied) - everything else this controller does lives in, and is +// covered by, BaseProductControllerTests.cs (see that file's own header comment on the Task 13 +// consolidation this replaces). The condition is unusual (warns when NOT limited to stores at all, or +// when limited AND the staff member's store is one of several) and easy to get backwards, per the +// plan's own warning - this test exists so a future regression here fails loudly instead of silently. +[TestClass] +public class ProductControllerTests +{ + private const string StaffStoreId = "store-1"; + private const string OtherStoreId = "store-2"; + + private ProductController _controller; + private Mock _productServiceMock; + private Mock> _scopeMock; + + [TestInitialize] + public void Setup() + { + _productServiceMock = new Mock(); + var productViewModelServiceMock = new Mock(); + var translationServiceMock = new Mock(); + translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StaffStoreId }); + var contextAccessorMock = new Mock(); + contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.DefaultStoreId).Returns(StaffStoreId); + _scopeMock.Setup(s => s.CanView(It.IsAny())).ReturnsAsync(true); + + var languageServiceMock = new Mock(); + languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())) + .ReturnsAsync(new List()); + + _controller = new ProductController( + productViewModelServiceMock.Object, + _productServiceMock.Object, + new Mock().Object, + languageServiceMock.Object, + translationServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _scopeMock.Object); + + var httpContext = new DefaultHttpContext(); + _controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } + + private bool WarningWasRaised() + => _controller.TempData["grand.notifications.Warning"] is List { Count: > 0 }; + + [TestMethod] + public async Task EditGet_ProductNotLimitedToAnyStore_RaisesWarning() + { + var product = new Product { Id = "p1", LimitedToStores = false }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + + await _controller.Edit("p1"); + + Assert.IsTrue(WarningWasRaised(), "A product visible to every store must warn a store-scoped editor."); + } + + [TestMethod] + public async Task EditGet_ProductLimitedToStaffStoreAndAnotherStore_RaisesWarning() + { + var product = new Product + { Id = "p1", LimitedToStores = true, Stores = [StaffStoreId, OtherStoreId] }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + + await _controller.Edit("p1"); + + Assert.IsTrue(WarningWasRaised(), + "A product shared with another store beyond the staff member's own must still warn."); + } + + [TestMethod] + public async Task EditGet_ProductLimitedToStaffStoreOnly_NoWarning() + { + var product = new Product { Id = "p1", LimitedToStores = true, Stores = [StaffStoreId] }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + + await _controller.Edit("p1"); + + Assert.IsFalse(WarningWasRaised(), + "A product exclusive to the staff member's own store needs no cross-store warning."); + } +} diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index d01a1ef2dd..aacd760d98 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -2,8 +2,6 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; using Grand.Web.Admin.Extensions; using Grand.Web.AdminShared.Controllers; From 8bc4b86970483a4d2e82b6138ad3ae33d75ea74f Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 16:29:55 +0200 Subject: [PATCH 055/147] Delete Vendor's duplicate ProductViewModelService/interface, use AdminShared's (ARCH-001 Phase 1) --- .../Areas/Vendor/Views/_ViewImports.cshtml | 5 +- .../Controllers/ProductController.cs | 2625 +---------------- .../Interfaces/IProductViewModelService.cs | 165 -- .../Services/ProductViewModelService.cs | 2326 --------------- .../Startup/StartupApplication.cs | 5 +- ...uctSpecificationAttributeModelValidator.cs | 38 - .../Catalog/BundleProductModelValidator.cs | 24 - .../ProductAttributeValueModelValidator.cs | 49 - .../Validators/Catalog/ProductValidVendor.cs | 44 - .../Validators/Catalog/ProductValidator.cs | 28 - 10 files changed, 64 insertions(+), 5245 deletions(-) delete mode 100644 src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs delete mode 100644 src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs delete mode 100644 src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs delete mode 100644 src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs delete mode 100644 src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs delete mode 100644 src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs delete mode 100644 src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml index 38ef91d93e..83c4d41cb0 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/_ViewImports.cshtml @@ -26,7 +26,10 @@ @using Grand.Web.Common.Localization @using Grand.Web.Vendor.Extensions; @using Grand.Web.Vendor.Models.Common; -@using Grand.Web.Vendor.Models.Catalog; +@* Product views bind to Grand.Web.AdminShared's ProductModel family (ARCH-001 Phase 1 Task 12) - + Grand.Web.Vendor.Models.Catalog's own ProductModel etc. are no longer used by any controller/view + and importing both would make bare "ProductModel" ambiguous. *@ +@using Grand.Web.AdminShared.Models.Catalog; @using Grand.Web.Vendor.Models.Orders; @using Grand.Web.Vendor.Models.Shipment; @using Grand.Web.Vendor.Models.MerchandiseReturn; diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 17eb92f430..3ff638099e 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -1,2588 +1,75 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Permissions; using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Media; using Grand.Infrastructure; -using Grand.Web.Common.DataSource; -using Grand.Web.Common.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; -using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Grand.Web.Vendor.Models.Orders; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; -using Grand.Web.Common.Helpers; -using Grand.SharedKernel.Extensions; namespace Grand.Web.Vendor.Controllers; -[PermissionAuthorize(PermissionSystemName.Products)] -public class ProductController : BaseVendorController +// Reduced to a thin subclass of BaseProductController (ARCH-001 Phase 1 Task 11). All 24 regions of +// behavior live in the shared base; this class only supplies Vendor's DI wiring, the attributes that +// used to arrive transitively via BaseVendorController, and the 6 vendor-specific hooks +// BaseProductController's own doc comments explicitly assign to "a future Vendor subclass" once hosts +// are subclassed onto it (this task). BaseProductController can't inherit any single host's base +// controller (it's shared across Admin/Store/Vendor, each with a different [Area]/[Authorize*] pair), +// so each subclass restates its own host's attribute set explicitly. No EditWarningCheck override +// needed - Vendor's original had no equivalent branch. +// +// NOT wired into DI yet: this file compiles fine (it references AdminShared's IProductViewModelService +// directly), but Vendor's DI container still only registers its own old, duplicate +// Grand.Web.Vendor.Interfaces.IProductViewModelService/ProductViewModelService - nothing registers +// AdminShared's IProductViewModelService for Vendor yet, so this constructor cannot be resolved at +// runtime until Task 12 deletes Vendor's duplicate and rewires DI to AdminShared's implementation (see +// Task 11's plan Step 4). Left as an uncommitted working-tree change per plan Step 5 until Task 12. +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + ILanguageService languageService, + ITranslationService translationService, + IProductReservationService productReservationService, + IAuctionService auctionService, + IDateTimeService dateTimeService, + IPermissionService permissionService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope, + IContextAccessor contextAccessor) + : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, + translationService, productReservationService, auctionService, dateTimeService, permissionService, + enumTranslationService, scope) { - #region Constructors + // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only + // sees the subset of a grouped product's associated products that they themselves own. Overriding + // the base's empty default, per BaseProductController.AssociatedProductVendorId's own doc comment. + protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; - public ProductController( - IProductViewModelService productViewModelService, - IProductService productService, - IInventoryManageService inventoryManageService, - IContextAccessor contextAccessor, - ILanguageService languageService, - ITranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, - IDateTimeService dateTimeService, - IPermissionService permissionService, - IEnumTranslationService enumTranslationService) - { - _productViewModelService = productViewModelService; - _productService = productService; - _inventoryManageService = inventoryManageService; - _contextAccessor = contextAccessor; - _languageService = languageService; - _translationService = translationService; - _productReservationService = productReservationService; - _auctionService = auctionService; - _dateTimeService = dateTimeService; - _permissionService = permissionService; - _enumTranslationService = enumTranslationService; - } + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #endregion + protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #region Fields + protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - private readonly IProductViewModelService _productViewModelService; - private readonly IProductService _productService; - private readonly IInventoryManageService _inventoryManageService; - private readonly IContextAccessor _contextAccessor; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IProductReservationService _productReservationService; - private readonly IAuctionService _auctionService; - private readonly IDateTimeService _dateTimeService; - private readonly IPermissionService _permissionService; - private readonly IEnumTranslationService _enumTranslationService; + protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); - #endregion - - #region Methods - - private Task<(bool allow, string message)> CheckAccessToProduct(Product product) - { - if (product == null) return Task.FromResult((false, "Product not exists")); - - //a vendor should have access only to his products - return !_contextAccessor.WorkContext.HasAccessToProduct(product) - ? Task.FromResult((false, "This is not your product")) - : Task.FromResult<(bool allow, string message)>((true, null)); - } - - #region Product list / create / edit / delete - - //list products - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public async Task List() - { - var model = await _productViewModelService.PrepareProductListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.List)] - [HttpPost] - public async Task ProductList(DataSourceRequest command, ProductListModel model) - { - var (productModels, totalCount) = - await _productViewModelService.PrepareProductsModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = productModels.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task GoToSku(ProductListModel model) - { - var sku = model.GoDirectlyToSku; - - //try to load a product entity - var product = await _productService.GetProductBySku(sku); - if (product != null) return RedirectToAction("Edit", "Product", new { id = product.Id }); - - //not found - Warning(_translationService.GetResource("Vendor.Catalog.Products.List.SkuNotFound")); - return RedirectToAction("List", "Product"); - } - - //create product - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new ProductModel(); - await _productViewModelService.PrepareProductModel(model, null, true); - await AddLocales(_languageService, model.Locales); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(ProductModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var product = await _productViewModelService.InsertProductModel(model); - Success(_translationService.GetResource("Vendor.Catalog.Products.Added")); - return continueEditing ? RedirectToAction("Edit", new { id = product.Id }) : RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, null, false); - return View(model); - } - - //edit product - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var product = await _productService.GetProductById(id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - var model = product.ToModel(_dateTimeService); - //model.Ticks = product.UpdatedOnUtc.Ticks; - - await _productViewModelService.PrepareProductModel(model, product, false); - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = product.GetTranslation(x => x.Name, languageId, false); - locale.ShortDescription = product.GetTranslation(x => x.ShortDescription, languageId, false); - locale.FullDescription = product.GetTranslation(x => x.FullDescription, languageId, false); - locale.MetaKeywords = product.GetTranslation(x => x.MetaKeywords, languageId, false); - locale.MetaDescription = product.GetTranslation(x => x.MetaDescription, languageId, false); - locale.MetaTitle = product.GetTranslation(x => x.MetaTitle, languageId, false); - locale.SeName = product.GetSeName(languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(ProductModel model, bool continueEditing) - { - var product = await _productService.GetProductById(model.Id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - if (model.Ticks != product.Ticks) - { - Error(_translationService.GetResource("Vendor.Catalog.Products.Fields.ChangedWarning")); - return RedirectToAction("Edit", new { id = product.Id }); - } - - if (ModelState.IsValid) - { - product = await _productViewModelService.UpdateProductModel(product, model); - - Success(_translationService.GetResource("Vendor.Catalog.Products.Updated")); - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = product.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductModel(model, product, false); - - return View(model); - } - - //delete product - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var product = await _productService.GetProductById(id, true); - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - //No product found with the specified id, or it's not this vendor's product - return RedirectToAction("List"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProduct(product); - Success(_translationService.GetResource("Vendor.Catalog.Products.Deleted")); - return RedirectToAction("List"); - } - - Error(ModelState); - return RedirectToAction("Edit", new { id }); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task DeleteSelected(ICollection selectedIds) - { - if (selectedIds != null) await _productViewModelService.DeleteSelected(selectedIds.ToList()); - - return Json(new { Result = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task CopyProduct(ProductModel model, - [FromServices] ICopyProductService copyProductService, [FromServices] IPictureService pictureService) - { - var copyModel = model.CopyProductModel; - try - { - var originalProduct = await _productService.GetProductById(copyModel.Id, true); - //a vendor should have access only to his products - if (originalProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(originalProduct)) - return RedirectToAction("List"); - - var newProduct = await copyProductService.CopyProduct(originalProduct, - copyModel.Name, copyModel.Published); - - if (copyModel.CopyImages) await CopyImages(originalProduct, newProduct, pictureService); - - Success("The product has been copied successfully"); - return RedirectToAction("Edit", new { id = newProduct.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = copyModel.Id }); - } - } - - private async Task CopyImages(Product originalProduct, Product newProduct, IPictureService pictureService) - { - foreach (var productPicture in originalProduct.ProductPictures) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var pictureCopy = await pictureService.InsertPicture( - await pictureService.LoadPictureBinary(picture), - picture.MimeType, - pictureService.GetPictureSeName(newProduct.Name), - picture.AltAttribute, - picture.TitleAttribute, - false, - Reference.Product, - newProduct.Id); - - await _productService.InsertProductPicture(new ProductPicture { - PictureId = pictureCopy.Id, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault - }, newProduct.Id); - } - } - - #endregion - - #region Required products - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task LoadProductFriendlyNames(string productIds) - { - var result = ""; - - if (!string.IsNullOrWhiteSpace(productIds)) - { - var rangeArray = productIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x.Trim()) - .ToList(); - - var products = await _productService.GetProductsByIds(Enumerable.ToArray(rangeArray), true); - for (var i = 0; i <= products.Count - 1; i++) - { - if (!_contextAccessor.WorkContext.HasAccessToProduct(products[i])) continue; - - result += products[i].Name; - if (i != products.Count - 1) - result += ", "; - } - } - - return Json(new { Text = result }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RequiredProductAddPopup(string productIdsInput) - { - var model = await _productViewModelService.PrepareAddRequiredProductModel(); - ViewBag.productIdsInput = productIdsInput; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RequiredProductAddPopupList(DataSourceRequest command, - ProductModel.AddRequiredProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Product categories - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCategoryList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productCategoriesModel = await _productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product collections - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductCollectionList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productCollectionsModel = await _productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - try - { - await _productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Related products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await _productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await _productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); - - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - await _productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await _productService.GetProductById(x))?.Name - }); - - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var vendorId = ""; - if (_contextAccessor.WorkContext.CurrentVendor != null) vendorId = _contextAccessor.WorkContext.CurrentVendor.Id; - - var associatedProducts = await _productService.GetAssociatedProducts(productId, - vendorId: vendorId, - showHidden: true); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var associatedProduct = await _productService.GetProductById(model.Id); - if (associatedProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(associatedProduct)) - throw new ArgumentException("No associated product found with the specified id"); - - associatedProduct.DisplayOrder = model.DisplayOrder; - await _productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.Id); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No associated product found with the specified id"); - - await _productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - var model = await _productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await _productViewModelService.InsertAssociatedProductModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await _permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new { - success = false, - message = "No files uploaded" - }); - - var product = await _productService.GetProductById(objectId); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Json(new { - success = false, - message = "Access denied - vendor permissions" - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = Path.GetFileName(file.FileName); - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (SharedKernel.Extensions.FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, - reference: reference, objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await _productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productPicturesModel = await _productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await _productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await _productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - await _productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productSpecsModel = await _productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productSpecsModel, - Total = productSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await _productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - return (await specificationAttributeService.GetSpecificationAttributes()) - .Select(sa => new SelectListItem { Text = sa.Name, Value = sa.Id }).ToList(); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await _productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await _permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var model = new OrderListModel { - ProductId = productId - }; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", "", productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await _productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelAll(ProductListModel model, - [FromServices] IExportManager exportManager) - { - var products = await _productViewModelService.PrepareProducts(model); - try - { - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - catch (Exception exc) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelSelected(string selectedIds, - [FromServices] IExportManager exportManager) - { - var products = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - products.AddRange(await _productService.GetProductsByIds(ids, true)); - } - - //a vendor should have access only to his products - products = products.Where(p => _contextAccessor.WorkContext.HasAccessToProduct(p)).ToList(); - - var bytes = await exportManager.Export(products); - return File(bytes, "text/xls", "products.xlsx"); - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - var model = await _productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - var (bulkEditProductModels, totalCount) = - await _productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - if (products != null) await _productViewModelService.UpdateBulkEdit(products.ToList()); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - if (products != null) await _productViewModelService.DeleteBulkEdit(products.ToList()); - - return new JsonResult(""); - } - - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await _productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = productId; - - await _productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(string productId, ProductModel.ProductPriceModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = productId; - await _productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var tierPricesModel = await _productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var tierPrice = model.ToEntity(_dateTimeService); - await _productService.InsertTierPrice(tierPrice, model.ProductId); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Content("This is not your product"); - - var model = tierPrice.ToModel(_dateTimeService); - model.ProductId = productId; - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(productId, true); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, _dateTimeService); - await _productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await _productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId, true); - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await _productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var attributesModel = await _productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await _productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - else - { - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - if (string.IsNullOrEmpty(model.Id)) - await _productViewModelService.InsertProductAttributeMappingModel(model); - else - await _productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - //a vendor should have access only to his products - if (!_contextAccessor.WorkContext.HasAccessToProduct(product)) - return Content("This is not your product"); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - - var model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, - model); - return Content(""); - } - - Error(ModelState); - model = await _productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - await _productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, - model); - } - - return Content(ModelState.GetErrors()); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await _productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(product, - productAttributeMapping); - //locales - await AddLocales(_languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - - await _productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - return Content(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await _productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await _productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - var model = await _productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var (products, totalCount) = - await _productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await _productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null || !_contextAccessor.WorkContext.HasAccessToProduct(associatedProduct)) - return Content("Cannot load a product"); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var combinationsModel = await _productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await _productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await _inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string id) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return Content(permission.message); - - var model = await _productViewModelService.PrepareProductAttributeCombinationModel(product, id); - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - var warnings = - await _productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - - //If we got this far, something failed, redisplay form - await _productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - await _productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - if (ModelState.IsValid) - { - await _productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await _inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(string productId, - string productAttributeCombinationId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var tierPriceModel = - await _productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert( - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - var combination = - product.ProductAttributeCombinations.FirstOrDefault( - x => x.Id == model.ProductAttributeCombinationId); - if (combination != null) - await _productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, - combination, model); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate( - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (ModelState.IsValid) - { - var product = await _productService.GetProductById(model.ProductId); - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.ProductAttributeCombinationId); - if (combination != null) - await _productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await _productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } - - return new JsonResult(""); - } - - #endregion - - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - - var permission = await CheckAccessToProduct(product); - if (!permission.allow) - return ErrorForKendoGridJson(permission.message); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(productId, null, null, - command.Page - 1, command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(ProductModel.GenerateCalendarModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var reservations = - await _productReservationService.GetProductReservationsByProductId(model.ProductId, null, null); - if (reservations.Any()) - if ((product.IntervalUnitId is IntervalUnit.Minute or IntervalUnit.Hour && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new { - errors = _translationService.GetResource( - "Vendor.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = - (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await _productService.UpdateProductField(product, x => x.Interval, model.Interval); - await _productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await _productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { - IntervalUnit.Minute => model.Interval, - IntervalUnit.Hour => model.Interval * 60, - IntervalUnit.Day => model.Interval * 60 * 24, - _ => 0 - }; - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, - model.StartDate.Value.Day, 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, - model.EndDate.Value.Day, 23, 59, 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - - if (insert) - { - if (counter++ > 1000) - break; - - await _productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = model.ProductId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + _enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch - { - // ignored - } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = - (await _productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await _productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _productReservationService.GetProductReservation(model.ReservationId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await _productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = _translationService.GetResource( - "Vendor.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await _productService.GetProductById(productId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var (bidModels, totalCount) = - await _productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) - { - var product = await _productService.GetProductById(model.ProductId); - if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var toDelete = await _auctionService.GetBid(model.BidId); - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await _auctionService.DeleteBid(toDelete); - return Json(""); - } - - return Json(new DataSourceResult - { Errors = _translationService.GetResource("Vendor.Catalog.Products.Bids.CantDeleteWithOrder") }); - } - - return Json(new DataSourceResult { Errors = "Bid not exists" }); - } - - #endregion - - #endregion -} \ No newline at end of file + protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} diff --git a/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs b/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs deleted file mode 100644 index e73029d980..0000000000 --- a/src/Web/Grand.Web.Vendor/Interfaces/IProductViewModelService.cs +++ /dev/null @@ -1,165 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Domain.Media; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Interfaces; - -public interface IProductViewModelService -{ - Task PrepareProductModel(ProductModel model, Product product, bool setPredefinedValues); - - Task PrepareProductReviewModel(ProductReviewModel model, ProductReview productReview, bool excludeProperties, - bool formatReviewText); - - Task OutOfStockNotifications(Product product, int prevStockQuantity, - List prevMultiWarehouseStock); - - Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination); - - Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, Product product); - Task SaveProductWarehouseInventory(Product product, IList model); - Task PrepareTierPriceModel(ProductModel.TierPriceModel model); - Task PrepareProductAttributeValueModel(Product product, ProductModel.ProductAttributeValueModel model); - Task PrepareProductListModel(); - - Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel(ProductListModel model, - int pageIndex, int pageSize); - - Task> PrepareProducts(ProductListModel model); - Task InsertProductModel(ProductModel model); - Task UpdateProductModel(Product product, ProductModel model); - Task DeleteProduct(Product product); - Task DeleteSelected(IEnumerable selectedIds); - Task PrepareAddRequiredProductModel(); - - Task<(IList products, int totalCount)> PrepareProductModel(ProductModel.AddProductModel model, - int pageIndex, int pageSize); - - Task> PrepareProductCategoryModel(Product product); - Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model); - Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model); - Task DeleteProductCategory(string id, string productId); - Task> PrepareProductCollectionModel(Product product); - Task InsertProductCollection(ProductModel.ProductCollectionModel model); - Task UpdateProductCollection(ProductModel.ProductCollectionModel model); - Task DeleteProductCollection(string id, string productId); - Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model); - Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model); - Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model); - Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model); - Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model); - Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model); - Task InsertBundleProductModel(ProductModel.AddBundleProductModel model); - Task UpdateBundleProductModel(ProductModel.BundleProductModel model); - Task DeleteBundleProductModel(ProductModel.BundleProductModel model); - Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model); - Task DeleteCrossSellProduct(string productId, string crossSellProductId); - Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model); - Task DeleteRecommendedProduct(string productId, string recommendedProductId); - Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model); - Task DeleteAssociatedProduct(Product product); - Task PrepareRelatedProductModel(); - Task PrepareSimilarProductModel(); - Task PrepareBundleProductModel(); - Task PrepareCrossSellProductModel(); - Task PrepareRecommendedProductModel(); - Task PrepareAssociatedProductModel(); - Task PrepareBulkEditListModel(); - - Task<(IEnumerable bulkEditProductModels, int totalCount)> PrepareBulkEditProductModel( - BulkEditListModel model, int pageIndex, int pageSize); - - Task UpdateBulkEdit(IEnumerable products); - - Task DeleteBulkEdit(IEnumerable products); - - //tier-prices - Task> PrepareTierPriceModel(Product product); - - Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode(string productId, int pageIndex, - int pageSize); - - Task PrepareProductAttributeMappingModel(Product product); - - Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model); - - Task> PrepareProductAttributeMappingModels(Product product); - Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeValidationRulesModel(ProductAttributeMapping productAttributeMapping, - ProductModel.ProductAttributeMappingModel model); - - Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task UpdateProductAttributeConditionModel(Product product, ProductAttributeMapping productAttributeMapping, - ProductAttributeConditionModel model); - - Task PrepareProductAttributeValueModel(Product product, - ProductAttributeMapping productAttributeMapping); - - Task> PrepareProductAttributeValueModels(Product product, - ProductAttributeMapping productAttributeMapping); - - Task PrepareProductAttributeValueModel(ProductAttributeMapping pa, - ProductAttributeValue pav); - - Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model); - Task UpdateProductAttributeValueModel(ProductAttributeValue pav, ProductModel.ProductAttributeValueModel model); - - Task - PrepareAssociateProductToAttributeValueModel(); - - Task> PrepareProductAttributeCombinationModel(Product product); - - Task PrepareProductAttributeCombinationModel(Product product, - string combinationId); - - Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model); - - Task GenerateAllAttributeCombinations(Product product); - - Task ClearAllAttributeCombinations(Product product); - - Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId); - - Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model); - - Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice); - - //Pictures - Task> PrepareProductPicturesModel(Product product); - - Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel(Product product, - ProductPicture productPicture); - - Task InsertProductPicture(Product product, Picture picture, int displayOrder); - Task UpdateProductPicture(ProductModel.ProductPictureModel model); - Task DeleteProductPicture(ProductModel.ProductPictureModel model); - - //Product specification - Task> PrepareProductSpecificationAttributeModel(Product product); - - Task InsertProductSpecificationAttributeModel(ProductModel.AddProductSpecificationAttributeModel model, - Product product); - - Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model); - - Task DeleteProductSpecificationAttribute(Product product, ProductSpecificationAttribute psa); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs b/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs deleted file mode 100644 index 8b39aa20cd..0000000000 --- a/src/Web/Grand.Web.Vendor/Services/ProductViewModelService.cs +++ /dev/null @@ -1,2326 +0,0 @@ -using Grand.Business.Core.Extensions; -using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; -using Grand.Business.Core.Interfaces.Catalog.Directory; -using Grand.Business.Core.Interfaces.Catalog.Prices; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Catalog.Tax; -using Grand.Business.Core.Interfaces.Checkout.Shipping; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Seo; -using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Business.Core.Interfaces.Customers; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Domain.Directory; -using Grand.Domain.Localization; -using Grand.Domain.Media; -using Grand.Domain.Tax; -using Grand.Infrastructure; -using Grand.SharedKernel.Extensions; -using Grand.Web.Common.Extensions; -using Grand.Web.Common.Localization; -using Grand.Web.Vendor.Extensions; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.Net; -using ProductExtensions = Grand.Domain.Catalog.ProductExtensions; - -namespace Grand.Web.Vendor.Services; - -public class ProductViewModelService( - IProductService productService, - IInventoryManageService inventoryManageService, - IPictureService pictureService, - IProductAttributeService productAttributeService, - ICurrencyService currencyService, - IMeasureService measureService, - IDateTimeService dateTimeService, - ICollectionService collectionService, - IProductCollectionService productCollectionService, - ICategoryService categoryService, - IProductCategoryService productCategoryService, - ITranslationService translationService, - IProductLayoutService productLayoutService, - ISpecificationAttributeService specificationAttributeService, - IContextAccessor contextAccessor, - IWarehouseService warehouseService, - IDeliveryDateService deliveryDateService, - ITaxCategoryService taxCategoryService, - ICustomerService customerService, - IStoreService storeService, - IOutOfStockSubscriptionService outOfStockSubscriptionService, - ILanguageService languageService, - IProductAttributeFormatter productAttributeFormatter, - IStockQuantityService stockQuantityService, - IAuctionService auctionService, - IPriceFormatter priceFormatter, - CurrencySettings currencySettings, - MeasureSettings measureSettings, - TaxSettings taxSettings, - ISeNameService seNameService, - IEnumTranslationService enumTranslationService) - : IProductViewModelService -{ - public virtual async Task PrepareAddProductAttributeCombinationModel(ProductAttributeCombinationModel model, - Product product) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(product); - - if (product.UseMultipleWarehouses) model.UseMultipleWarehouses = product.UseMultipleWarehouses; - - if (string.IsNullOrEmpty(model.Id)) - { - model.ProductId = product.Id; - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - foreach (var attribute in attributes) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeCombinationModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = productAttribute.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = attribute.ProductAttributeValues; - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeCombinationModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - } - - model.ProductAttributes.Add(attributeModel); - } - } - - - if (!string.IsNullOrEmpty(model.PictureId)) - { - var pictureThumbnailUrl = await pictureService.GetPictureUrl(model.PictureId, 100, false); - model.PictureThumbnailUrl = pictureThumbnailUrl; - } - - foreach (var picture in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = picture.Id, - ProductId = product.Id, - PictureId = picture.PictureId, - PictureUrl = await pictureService.GetPictureUrl(picture.PictureId), - DisplayOrder = picture.DisplayOrder, - IsDefault = picture.IsDefault - }); - - model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; - } - - public virtual async Task PrepareTierPriceModel(ProductModel.TierPriceModel model) - { - foreach (var currency in await currencyService.GetAllCurrencies()) - model.AvailableCurrencies.Add( - new SelectListItem { Text = currency.Name, Value = currency.CurrencyCode }); - } - - public virtual async Task PrepareProductAttributeValueModel(Product product, - ProductModel.ProductAttributeValueModel model) - { - //pictures - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - - var associatedProduct = await productService.GetProductById(model.AssociatedProductId); - model.AssociatedProductName = associatedProduct != null ? associatedProduct.Name : ""; - } - - public virtual async Task OutOfStockNotifications(Product product, int prevStockQuantity, - List prevMultiWarehouseStock - ) - { - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - stockQuantityService.GetTotalStockQuantity(product, total: true) > 0 && - prevStockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - product.UseMultipleWarehouses && - product.Published) - { - foreach (var prevstock in prevMultiWarehouseStock) - if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) - { - var actualStock = - product.ProductWarehouseInventory.FirstOrDefault( - x => x.WarehouseId == prevstock.WarehouseId); - if (actualStock != null) - if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - prevstock.WarehouseId); - } - - if (product.ProductWarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) - if (prevMultiWarehouseStock.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - } - } - - public virtual async Task OutOfStockNotifications(Product product, ProductAttributeCombination combination, - ProductAttributeCombination prevcombination) - { - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - combination.StockQuantity > 0 && - prevcombination.StockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, combination.Attributes, - ""); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - product.UseMultipleWarehouses && - product.Published) - { - foreach (var prevstock in prevcombination.WarehouseInventory) - if (prevstock.StockQuantity - prevstock.ReservedQuantity <= 0) - { - var actualStock = - combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == prevstock.WarehouseId); - if (actualStock != null) - if (actualStock.StockQuantity - actualStock.ReservedQuantity > 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - combination.Attributes, prevstock.WarehouseId); - } - - if (combination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) > 0) - if (prevcombination.WarehouseInventory.Sum(x => x.StockQuantity - x.ReservedQuantity) <= 0) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, - combination.Attributes, ""); - } - } - - public virtual async Task PrepareProductModel(ProductModel model, Product product, - bool setPredefinedValues) - { - ArgumentNullException.ThrowIfNull(model); - - model.PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode; - model.BaseWeightIn = (await measureService.GetMeasureWeightById(measureSettings.BaseWeightId))?.Name; - model.BaseDimensionIn = - (await measureService.GetMeasureDimensionById(measureSettings.BaseDimensionId))?.Name; - - if (product != null) - { - //date - model.CreatedOn = dateTimeService.ConvertToUserTime(product.CreatedOnUtc, DateTimeKind.Utc); - model.UpdatedOn = product.UpdatedOnUtc.HasValue - ? dateTimeService.ConvertToUserTime(product.UpdatedOnUtc.Value, DateTimeKind.Utc) - : null; - - //parent grouped product - var parentGroupedProduct = await productService.GetProductById(product.ParentGroupedProductId); - if (parentGroupedProduct != null) - { - model.AssociatedToProductId = product.ParentGroupedProductId; - model.AssociatedToProductName = parentGroupedProduct.Name; - } - - //reservation - model.CalendarModel.ProductId = product.Id; - model.CalendarModel.Interval = product.Interval; - model.CalendarModel.IntervalUnit = (int)product.IntervalUnitId; - model.CalendarModel.IncBothDate = product.IncBothDate; - - model.AutoAddRequiredProducts = product.AutoAddRequiredProducts; - //product attributes - foreach (var productAttribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttributes.Add(new SelectListItem { - Text = productAttribute.Name, - Value = productAttribute.Id - }); - } - - //copy product - if (product != null) - { - model.CopyProductModel.Id = product.Id; - model.CopyProductModel.Name = "Copy of " + product.Name; - model.CopyProductModel.Published = true; - model.CopyProductModel.CopyImages = true; - } - - //layouts - var layouts = await productLayoutService.GetAllProductLayouts(); - foreach (var layout in layouts) - model.AvailableProductLayouts.Add(new SelectListItem { - Text = layout.Name, - Value = layout.Id - }); - - //delivery dates - model.AvailableDeliveryDates.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.Fields.DeliveryDate.None"), - Value = "" - }); - var deliveryDates = await deliveryDateService.GetAllDeliveryDates(); - foreach (var deliveryDate in deliveryDates) - model.AvailableDeliveryDates.Add(new SelectListItem { - Text = deliveryDate.Name, - Value = deliveryDate.Id - }); - - //warehouses - var warehouses = await warehouseService.GetAllWarehouses(); - model.AvailableWarehouses.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.Fields.Warehouse.None"), - Value = "" - }); - foreach (var warehouse in warehouses) - model.AvailableWarehouses.Add(new SelectListItem { - Text = warehouse.Name, - Value = warehouse.Id - }); - - //multiple warehouses - foreach (var warehouse in warehouses) - { - var pwiModel = new ProductModel.ProductWarehouseInventoryModel { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name, - WarehouseCode = warehouse.Code - }; - if (product != null) - { - var pwi = product.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (pwi != null) - { - pwiModel.WarehouseUsed = true; - pwiModel.StockQuantity = pwi.StockQuantity; - pwiModel.ReservedQuantity = pwi.ReservedQuantity; - } - } - - model.ProductWarehouseInventoryModels.Add(pwiModel); - } - - //tax categories - var taxCategories = await taxCategoryService.GetAllTaxCategories(); - model.AvailableTaxCategories.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Configuration.Tax.Settings.TaxCategories.None"), - Value = "" - }); - foreach (var tc in taxCategories) - model.AvailableTaxCategories.Add(new SelectListItem { - Text = tc.Name, Value = tc.Id, - Selected = product != null && !setPredefinedValues && tc.Id == product.TaxCategoryId - }); - - //base-price units - var measureWeights = await measureService.GetAllMeasureWeights(); - foreach (var mw in measureWeights) - model.AvailableBasepriceUnits.Add(new SelectListItem { - Text = mw.Name, Value = mw.Id, - Selected = product != null && !setPredefinedValues && mw.Id == product.BasepriceUnitId - }); - foreach (var mw in measureWeights) - model.AvailableBasepriceBaseUnits.Add(new SelectListItem { - Text = mw.Name, Value = mw.Id, - Selected = product != null && !setPredefinedValues && mw.Id == product.BasepriceBaseUnitId - }); - - //units - var units = await measureService.GetAllMeasureUnits(); - model.AvailableUnits.Add(new SelectListItem { Text = "---", Value = "" }); - foreach (var un in units) - model.AvailableUnits.Add(new SelectListItem - { Text = un.Name, Value = un.Id, Selected = product != null && un.Id == product.UnitId }); - - //default values - if (setPredefinedValues) - { - model.MaxEnteredPrice = 1000; - model.RecurringCycleLength = 100; - model.RecurringTotalCycles = 10; - model.StockQuantity = 0; - model.NotifyAdminForQuantityBelow = 1; - model.OrderMinimumQuantity = 1; - model.OrderMaximumQuantity = 10000; - model.TaxCategoryId = taxSettings.DefaultTaxCategoryId; - model.IsShipEnabled = true; - model.AllowCustomerReviews = true; - model.Published = true; - model.VisibleIndividually = true; - } - } - - public virtual async Task SaveProductWarehouseInventory(Product product, - IList model) - { - ArgumentNullException.ThrowIfNull(product); - - if (product.ManageInventoryMethodId != ManageInventoryMethod.ManageStock) - return; - - if (!product.UseMultipleWarehouses) - return; - - var warehouses = await warehouseService.GetAllWarehouses(); - - foreach (var warehouse in warehouses) - { - var whim = model.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - var existingPwI = product.ProductWarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (existingPwI != null) - { - if (whim is { WarehouseUsed: true }) - { - //update existing record - existingPwI.StockQuantity = whim.StockQuantity; - existingPwI.ReservedQuantity = whim.ReservedQuantity; - await productService.UpdateProductWarehouseInventory(existingPwI, product.Id); - } - else - { - //delete. no need to store record for qty 0 - await productService.DeleteProductWarehouseInventory(existingPwI, product.Id); - } - } - else - { - if (whim is { WarehouseUsed: true }) - { - //no need to insert a record for qty 0 - existingPwI = new ProductWarehouseInventory { - WarehouseId = warehouse.Id, - StockQuantity = whim.StockQuantity, - ReservedQuantity = whim.ReservedQuantity - }; - product.ProductWarehouseInventory.Add(existingPwI); - await productService.InsertProductWarehouseInventory(existingPwI, product.Id); - } - } - } - - product.StockQuantity = product.ProductWarehouseInventory.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductWarehouseInventory.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - - public virtual async Task PrepareProductReviewModel(ProductReviewModel model, - ProductReview productReview, bool excludeProperties, bool formatReviewText) - { - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(model); - ArgumentNullException.ThrowIfNull(productReview); - - var product = await productService.GetProductById(productReview.ProductId); - var customer = await customerService.GetCustomerById(productReview.CustomerId); - var store = await storeService.GetStoreById(productReview.StoreId); - model.Id = productReview.Id; - model.StoreName = store != null ? store.Shortcut : ""; - model.ProductId = productReview.ProductId; - model.ProductName = product.Name; - model.CustomerId = productReview.CustomerId; - model.CustomerInfo = customer != null - ? !string.IsNullOrEmpty(customer.Email) - ? customer.Email - : translationService.GetResource("Vendor.Customers.Guest") - : ""; - model.Rating = productReview.Rating; - model.CreatedOn = dateTimeService.ConvertToUserTime(productReview.CreatedOnUtc, DateTimeKind.Utc); - model.Signature = productReview.Signature; - if (!excludeProperties) - { - model.Title = productReview.Title; - if (formatReviewText) - { - model.ReviewText = FormatText.ConvertText(productReview.ReviewText); - model.ReplyText = FormatText.ConvertText(productReview.ReplyText); - } - else - { - model.ReviewText = productReview.ReviewText; - model.ReplyText = productReview.ReplyText; - } - - model.IsApproved = productReview.IsApproved; - } - } - - public virtual async Task PrepareProductListModel() - { - var model = new ProductListModel(); - - //warehouses - model.AvailableWarehouses.Add(new SelectListItem - { Text = translationService.GetResource("Vendor.Common.All"), Value = " " }); - foreach (var wh in await warehouseService.GetAllWarehouses()) - model.AvailableWarehouses.Add(new SelectListItem { Text = wh.Name, Value = wh.Id }); - - //product types - model.AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - //"published" property - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - //4 - mark as new - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.All"), Value = " " - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.PublishedOnly"), - Value = "1" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.UnpublishedOnly"), - Value = "2" - }); - model.AvailablePublishedOptions.Add(new SelectListItem { - Text = translationService.GetResource("Vendor.Catalog.Products.List.SearchPublished.MarkAsNew"), - Value = "4" - }); - - return model; - } - - public virtual async Task<(IEnumerable productModels, int totalCount)> PrepareProductsModel( - ProductListModel model, int pageIndex, int pageSize) - { - var categoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.Add(model.SearchCategoryId); - - //include subcategories - if (model.SearchIncludeSubCategories && !string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.AddRange(await GetChildCategoryIds(model.SearchCategoryId)); - - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - bool? overridePublished = model.SearchPublishedId switch { - 1 => true, - 2 => false, - _ => null - }; - - bool? showOnHomePage = null; - if (model.SearchPublishedId == 3) - showOnHomePage = true; - - var markedAsNewOnly = model.SearchPublishedId == 4; - - var products = (await productService.SearchProducts( - categoryIds: categoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - warehouseId: model.SearchWarehouseId, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - pageIndex: pageIndex - 1, - pageSize: pageSize, - showHidden: true, - showOnHomePage: showOnHomePage, - overridePublished: overridePublished, - markedAsNewOnly: markedAsNewOnly - )).products; - - var items = new List(); - foreach (var x in products) - { - var productModel = x.ToModel(dateTimeService); - //"Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property. " - //also it improves performance - productModel.FullDescription = ""; - - //picture - var defaultProductPicture = x.ProductPictures.FirstOrDefault() ?? new ProductPicture(); - productModel.PictureThumbnailUrl = - await pictureService.GetPictureUrl(defaultProductPicture.PictureId, 100); - //product type - productModel.ProductTypeName = enumTranslationService.GetTranslationEnum(x.ProductTypeId); - //friendly stock quantity - //if a simple product AND "manage inventory" is "Track inventory", then display - if (x.ProductTypeId == ProductType.SimpleProduct && - x.ManageInventoryMethodId == ManageInventoryMethod.ManageStock) - productModel.StockQuantityStr = - stockQuantityService.GetTotalStockQuantity(x, total: true).ToString(); - items.Add(productModel); - } - - return (items, products.TotalCount); - } - - public virtual async Task> PrepareProducts(ProductListModel model) - { - var categoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.Add(model.SearchCategoryId); - - //include subcategories - if (model.SearchIncludeSubCategories && !string.IsNullOrEmpty(model.SearchCategoryId)) - categoryIds.AddRange(await GetChildCategoryIds(model.SearchCategoryId)); - - //0 - all (according to "ShowHidden" parameter) - //1 - published only - //2 - unpublished only - bool? overridePublished = model.SearchPublishedId switch { - 1 => true, - 2 => false, - _ => null - }; - - var products = (await productService.SearchProducts( - categoryIds: categoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - warehouseId: model.SearchWarehouseId, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - showHidden: true, - overridePublished: overridePublished - )).products; - - return products; - } - - public virtual async Task InsertProductModel(ProductModel model) - { - //product - var product = model.ToEntity(dateTimeService); - product.VendorId = contextAccessor.WorkContext.CurrentVendor?.Id; - - product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); - product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); - - await productService.InsertProduct(product); - - //search engine name - await seNameService.SaveSeName(product); - - //warehouses - await SaveProductWarehouseInventory(product, model.ProductWarehouseInventoryModels); - - return product; - } - - public virtual async Task UpdateProductModel(Product product, ProductModel model) - { - var prevStockQuantity = stockQuantityService.GetTotalStockQuantity(product, total: true); - var prevMultiWarehouseStock = product.ProductWarehouseInventory.Select(i => new ProductWarehouseInventory { - WarehouseId = i.WarehouseId, StockQuantity = i.StockQuantity, ReservedQuantity = i.ReservedQuantity - }) - .ToList(); - - //product - product = model.ToEntity(product, dateTimeService); - product.AutoAddRequiredProducts = model.AutoAddRequiredProducts; - - product.Locales = await seNameService.TranslationSeNameProperties(model.Locales, product, x => x.Name); - product.SeName = await seNameService.ValidateSeName(product, model.SeName, product.Name, true); - - await productService.UpdateProduct(product); - - //search engine name - await seNameService.SaveSeName(product); - - //warehouses - await SaveProductWarehouseInventory(product, model.ProductWarehouseInventoryModels); - - //picture seo names - await UpdatePictureSeoNames(product); - - //out of stock notifications - await OutOfStockNotifications(product, prevStockQuantity, prevMultiWarehouseStock); - - return product; - } - - public virtual async Task DeleteProduct(Product product) - { - await productService.DeleteProduct(product); - } - - public virtual async Task DeleteSelected(IEnumerable selectedIds) - { - var products = new List(); - products.AddRange(await productService.GetProductsByIds(selectedIds.ToArray(), true)); - for (var i = 0; i < products.Count; i++) - { - var product = products[i]; - //a vendor should have access only to his products - if (!contextAccessor.WorkContext.HasAccessToProduct(product)) - continue; - - await DeleteProduct(product); - } - } - - public virtual Task PrepareAddRequiredProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual async Task<(IList products, int totalCount)> PrepareProductModel( - ProductModel.AddProductModel model, int pageIndex, int pageSize) - { - var products = await productService.PrepareProductList(model.SearchCategoryId, model.SearchBrandId, - model.SearchCollectionId, string.Empty, contextAccessor.WorkContext.CurrentVendor.Id, model.SearchProductTypeId, - model.SearchProductName, pageIndex, pageSize); - return (products.Select(x => x.ToModel(dateTimeService)).ToList(), products.TotalCount); - } - - public virtual async Task> PrepareProductCategoryModel(Product product) - { - var productCategories = product.ProductCategories.OrderBy(x => x.DisplayOrder); - var items = new List(); - foreach (var x in productCategories) - { - var category = await categoryService.GetCategoryById(x.CategoryId); - items.Add(new ProductModel.ProductCategoryModel { - Id = x.Id, - Category = await categoryService.GetFormattedBreadCrumb(category), - ProductId = product.Id, - CategoryId = x.CategoryId, - DisplayOrder = x.DisplayOrder - }); - } - - return items; - } - - public virtual async Task InsertProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - if (product.ProductCategories.All(x => x.CategoryId != model.CategoryId)) - { - var productCategory = new ProductCategory { - CategoryId = model.CategoryId, - DisplayOrder = model.DisplayOrder - }; - await productCategoryService.InsertProductCategory(productCategory, product.Id); - } - } - - public virtual async Task UpdateProductCategoryModel(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == model.Id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - if (product.ProductCategories.Any(x => x.Id != model.Id && x.CategoryId == model.CategoryId)) - throw new ArgumentException("This category is already mapped with this product"); - productCategory.CategoryId = model.CategoryId; - productCategory.DisplayOrder = model.DisplayOrder; - - await productCategoryService.UpdateProductCategory(productCategory, product.Id); - } - - public virtual async Task DeleteProductCategory(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - - var productCategory = product.ProductCategories.FirstOrDefault(x => x.Id == id); - if (productCategory == null) - throw new ArgumentException("No product category mapping found with the specified id"); - - await productCategoryService.DeleteProductCategory(productCategory, product.Id); - } - - public virtual async Task> PrepareProductCollectionModel( - Product product) - { - var items = new List(); - foreach (var x in product.ProductCollections.OrderBy(x => x.DisplayOrder)) - items.Add(new ProductModel.ProductCollectionModel { - Id = x.Id, - Collection = (await collectionService.GetCollectionById(x.CollectionId)).Name, - ProductId = product.Id, - CollectionId = x.CollectionId, - DisplayOrder = x.DisplayOrder - }); - - return items; - } - - public virtual async Task InsertProductCollection(ProductModel.ProductCollectionModel model) - { - var collectionId = model.CollectionId; - var product = await productService.GetProductById(model.ProductId, true); - - if (product.ProductCollections.All(x => x.CollectionId != collectionId)) - { - var productCollection = new ProductCollection { - CollectionId = collectionId, - DisplayOrder = model.DisplayOrder - }; - await productCollectionService.InsertProductCollection(productCollection, model.ProductId); - } - } - - public virtual async Task UpdateProductCollection(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == model.Id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - if (product.ProductCollections.Any(x => x.Id != model.Id && x.CollectionId == model.CollectionId)) - throw new ArgumentException("This collection is already mapped with this product"); - - productCollection.CollectionId = model.CollectionId; - productCollection.DisplayOrder = model.DisplayOrder; - - await productCollectionService.UpdateProductCollection(productCollection, product.Id); - } - - public virtual async Task DeleteProductCollection(string id, string productId) - { - var product = await productService.GetProductById(productId, true); - - var productCollection = product.ProductCollections.FirstOrDefault(x => x.Id == id); - if (productCollection == null) - throw new ArgumentException("No product collection mapping found with the specified id"); - - await productCollectionService.DeleteProductCollection(productCollection, product.Id); - } - - public virtual async Task InsertRelatedProductModel(ProductModel.AddRelatedProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - var existingRelatedProducts = productId1.RelatedProducts; - if (model.ProductId == id) continue; - if (existingRelatedProducts.All(x => x.ProductId2 != id)) - { - var related = new RelatedProduct { - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.RelatedProducts.Add(related); - await productService.InsertRelatedProduct(related, model.ProductId); - } - } - } - - public virtual async Task UpdateRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - - var relatedProduct = product1.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - relatedProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task DeleteRelatedProductModel(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - - var relatedProduct = product.RelatedProducts.FirstOrDefault(x => x.Id == model.Id); - if (relatedProduct == null) - throw new ArgumentException("No related product found with the specified id"); - - await productService.DeleteRelatedProduct(relatedProduct, model.ProductId1); - } - - public virtual async Task InsertSimilarProductModel(ProductModel.AddSimilarProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - { - var existingSimilarProducts = productId1.SimilarProducts; - if (model.ProductId != id) - if (existingSimilarProducts.All(x => x.ProductId2 != id)) - { - var similar = new SimilarProduct { - ProductId1 = model.ProductId, - ProductId2 = id, - DisplayOrder = 1 - }; - productId1.SimilarProducts.Add(similar); - await productService.InsertSimilarProduct(similar); - } - } - } - } - - public virtual async Task UpdateSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product1 = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product1.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - similarProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateSimilarProduct(similarProduct); - } - - public virtual async Task DeleteSimilarProductModel(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1, true); - var similarProduct = product.SimilarProducts.FirstOrDefault(x => x.Id == model.Id); - if (similarProduct == null) - throw new ArgumentException("No similar product found with the specified id"); - - similarProduct.ProductId1 = model.ProductId1; - await productService.DeleteSimilarProduct(similarProduct); - } - - public virtual async Task InsertBundleProductModel(ProductModel.AddBundleProductModel model) - { - var productId1 = await productService.GetProductById(model.ProductId, true); - - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - { - var existingBundleProducts = productId1.BundleProducts; - if (model.ProductId != id) - if (existingBundleProducts.All(x => x.ProductId != id)) - { - var bundle = new BundleProduct { - ProductId = id, - DisplayOrder = 1, - Quantity = 1 - }; - productId1.BundleProducts.Add(bundle); - await productService.InsertBundleProduct(bundle, model.ProductId); - } - } - } - } - - public virtual async Task UpdateBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - //the IProductValidVendor filter validates model.ProductId (the bundled item), not - //model.ProductBundleId (the product actually mutated below) - check it explicitly - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - bundleProduct.ProductId = model.ProductId; - bundleProduct.Quantity = model.Quantity > 0 ? model.Quantity : 1; - bundleProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task DeleteBundleProductModel(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId, true); - //the IProductValidVendor filter validates model.ProductId (the bundled item), not - //model.ProductBundleId (the product actually mutated below) - check it explicitly - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) - throw new ArgumentException("No product found with the specified id"); - - var bundleProduct = product.BundleProducts.FirstOrDefault(x => x.Id == model.Id); - if (bundleProduct == null) - throw new ArgumentException("No bundle product found with the specified id"); - - await productService.DeleteBundleProduct(bundleProduct, model.ProductBundleId); - } - - public virtual async Task InsertCrossSellProductModel(ProductModel.AddCrossSellProductModel model) - { - var crossSellProduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product) && - crossSellProduct.CrossSellProduct.All(x => x != id)) - if (model.ProductId != id) - await productService.InsertCrossSellProduct( - new CrossSellProduct { - ProductId1 = model.ProductId, - ProductId2 = id - }); - } - } - - public virtual async Task DeleteCrossSellProduct(string productId, string crossSellProductId) - { - var crosssell = new CrossSellProduct { - ProductId1 = productId, - ProductId2 = crossSellProductId - }; - await productService.DeleteCrossSellProduct(crosssell); - } - - public virtual async Task InsertRecommendedProductModel(ProductModel.AddRecommendedProductModel model) - { - var mainproduct = await productService.GetProductById(model.ProductId, true); - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product != null && contextAccessor.WorkContext.HasAccessToProduct(product)) - if (mainproduct.RecommendedProduct.All(x => x != id)) - if (model.ProductId != id) - await productService.InsertRecommendedProduct(model.ProductId, id); - } - } - - public virtual async Task DeleteRecommendedProduct(string productId, string recommendedProductId) - { - await productService.DeleteRecommendedProduct(productId, recommendedProductId); - } - - public virtual async Task InsertAssociatedProductModel(ProductModel.AddAssociatedProductModel model) - { - foreach (var id in model.SelectedProductIds) - { - var product = await productService.GetProductById(id); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - product.ParentGroupedProductId = model.ProductId; - await productService.UpdateAssociatedProduct(product); - } - } - - public virtual async Task DeleteAssociatedProduct(Product product) - { - product.ParentGroupedProductId = ""; - await productService.UpdateAssociatedProduct(product); - } - - public virtual Task PrepareRelatedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareSimilarProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareBundleProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareCrossSellProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareRecommendedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareAssociatedProductModel() - { - var model = PrepareAddProductModel(); - return Task.FromResult(model); - } - - public virtual Task PrepareBulkEditListModel() - { - var model = new BulkEditListModel { - //product types - AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList() - }; - - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - return Task.FromResult(model); - } - - public virtual async Task<(IEnumerable bulkEditProductModels, int totalCount)> - PrepareBulkEditProductModel(BulkEditListModel model, int pageIndex, int pageSize) - { - var searchCategoryIds = new List(); - if (!string.IsNullOrEmpty(model.SearchCategoryId)) - searchCategoryIds.Add(model.SearchCategoryId); - - var products = (await productService.SearchProducts(categoryIds: searchCategoryIds, - brandId: model.SearchBrandId, - collectionId: model.SearchCollectionId, - vendorId: contextAccessor.WorkContext.CurrentVendor.Id, - productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, - keywords: model.SearchProductName, - pageIndex: pageIndex - 1, - pageSize: pageSize, - showHidden: true)).products; - - return (products.Select((Func)(x => - { - var productModel = new BulkEditProductModel { - Id = x.Id, - Name = x.Name, - Sku = x.Sku, - OldPrice = x.OldPrice, - Price = x.Price, - ManageInventoryMethodId = (int)x.ManageInventoryMethodId, - ManageInventoryMethod = enumTranslationService.GetTranslationEnum(x.ManageInventoryMethodId), - StockQuantity = x.StockQuantity, - Published = x.Published - }; - return productModel; - })), products.TotalCount); - } - - public virtual async Task UpdateBulkEdit(IEnumerable products) - { - foreach (var pModel in products) - { - //update - var product = await productService.GetProductById(pModel.Id, true); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - var prevStockQuantity = stockQuantityService.GetTotalStockQuantity(product, total: true); - - product.Sku = pModel.Sku; - product.Price = pModel.Price; - product.OldPrice = pModel.OldPrice; - product.StockQuantity = pModel.StockQuantity; - product.Published = pModel.Published; - product.Name = pModel.Name; - product.ManageInventoryMethodId = (ManageInventoryMethod)pModel.ManageInventoryMethodId; - await productService.UpdateProduct(product); - - //out of stock notifications - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStock && - product.BackorderModeId == BackorderMode.NoBackorders && - product.AllowOutOfStockSubscriptions && - stockQuantityService.GetTotalStockQuantity(product, total: true) > 0 && - prevStockQuantity <= 0 && !product.UseMultipleWarehouses && - product.Published) - await outOfStockSubscriptionService.SendNotificationsToSubscribers(product, ""); - } - } - - public virtual async Task DeleteBulkEdit(IEnumerable products) - { - foreach (var pModel in products) - { - //delete - var product = await productService.GetProductById(pModel.Id, true); - if (product == null || !contextAccessor.WorkContext.HasAccessToProduct(product)) continue; - - await productService.DeleteProduct(product); - } - } - - public virtual Task> PrepareTierPriceModel(Product product) - { - var items = new List(); - foreach (var x in product.TierPrices - .OrderBy(x => x.StoreId) - .ThenBy(x => x.Quantity)) - items.Add(new ProductModel.TierPriceModel { - Id = x.Id, - CurrencyCode = x.CurrencyCode, - ProductId = product.Id, - Quantity = x.Quantity, - Price = x.Price, - StartDateTime = x.StartDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.StartDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?(), - EndDateTime = x.EndDateTimeUtc.HasValue - ? dateTimeService.ConvertToUserTime(x.EndDateTimeUtc.Value, DateTimeKind.Utc) - : new DateTime?() - }); - - return Task.FromResult>(items); - } - - public virtual async Task<(IEnumerable bidModels, int totalCount)> PrepareBidMode( - string productId, int pageIndex, int pageSize) - { - var bids = await auctionService.GetBidsByProductId(productId, pageIndex - 1, pageSize); - var bidsModel = new List(); - foreach (var x in bids) - bidsModel.Add(new ProductModel.BidModel { - BidId = x.Id, - ProductId = x.ProductId, - Amount = priceFormatter.FormatPrice(x.Amount), - Date = dateTimeService.ConvertToUserTime(x.Date, DateTimeKind.Utc), - CustomerId = x.CustomerId, - Email = (await customerService.GetCustomerById(x.CustomerId))?.Email, - OrderId = x.OrderId - }); - - return (bidsModel, bids.TotalCount); - } - - public virtual async Task PrepareProductAttributeMappingModel( - Product product) - { - var model = new ProductModel.ProductAttributeMappingModel { - ProductId = product.Id - }; - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductAttributeMapping productAttributeMapping) - { - var model = productAttributeMapping.ToModel(); - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name, - Selected = attribute.Id == model.ProductAttributeId - }); - - return model; - } - - public virtual async Task PrepareProductAttributeMappingModel( - ProductModel.ProductAttributeMappingModel model) - { - foreach (var attribute in await productAttributeService.GetAllProductAttributes()) - model.AvailableProductAttribute.Add(new SelectListItem { - Value = attribute.Id, - Text = attribute.Name - }); - - return model; - } - - public virtual async Task> - PrepareProductAttributeMappingModels(Product product) - { - var items = new List(); - foreach (var x in product.ProductAttributeMappings.OrderBy(x => x.DisplayOrder)) - { - var attributeModel = new ProductModel.ProductAttributeMappingModel { - Id = x.Id, - ProductId = product.Id, - ProductAttribute = (await productAttributeService.GetProductAttributeById(x.ProductAttributeId)) - ?.Name, - ProductAttributeId = x.ProductAttributeId, - TextPrompt = x.TextPrompt, - IsRequired = x.IsRequired, - ShowOnCatalogPage = x.ShowOnCatalogPage, - AttributeControlType = enumTranslationService.GetTranslationEnum(x.AttributeControlTypeId), - AttributeControlTypeId = x.AttributeControlTypeId, - DisplayOrder = x.DisplayOrder, - Combination = x.Combination - }; - - - if (x.ShouldHaveValues()) - { - attributeModel.ShouldHaveValues = true; - attributeModel.TotalValues = x.ProductAttributeValues.Count; - } - - if (x.ValidationRulesAllowed()) - { - var validationRules = new StringBuilder(string.Empty); - attributeModel.ValidationRulesAllowed = true; - if (x.ValidationMinLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength"), - x.ValidationMinLength); - if (x.ValidationMaxLength != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength"), - x.ValidationMaxLength); - if (!string.IsNullOrEmpty(x.ValidationFileAllowedExtensions)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions"), - WebUtility.HtmlEncode(x.ValidationFileAllowedExtensions)); - if (x.ValidationFileMaximumSize != null) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize"), - x.ValidationFileMaximumSize); - if (!string.IsNullOrEmpty(x.DefaultValue)) - validationRules.AppendFormat("{0}: {1}
", - translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue"), - WebUtility.HtmlEncode(x.DefaultValue)); - attributeModel.ValidationRulesString = validationRules.ToString(); - } - - //currency any attribute can have condition. why not? - attributeModel.ConditionAllowed = true; - var conditionAttribute = product.ParseProductAttributeMappings(x.ConditionAttribute).FirstOrDefault(); - var conditionValue = product.ParseProductAttributeValues(x.ConditionAttribute).FirstOrDefault(); - if (conditionAttribute != null && conditionValue != null) - { - var productAttribute = - await productAttributeService.GetProductAttributeById(conditionAttribute.ProductAttributeId); - var paname = productAttribute != null ? productAttribute.Name : ""; - attributeModel.ConditionString = - $"{WebUtility.HtmlEncode(paname)}: {WebUtility.HtmlEncode(conditionValue.Name)}"; - } - else - { - attributeModel.ConditionString = string.Empty; - } - - items.Add(attributeModel); - } - - return items; - } - - public virtual async Task InsertProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - //insert mapping - var productAttributeMapping = model.ToEntity(); - //predefined values - var predefinedValues = (await productAttributeService.GetProductAttributeById(model.ProductAttributeId)) - .PredefinedProductAttributeValues; - foreach (var predefinedValue in predefinedValues) - { - var pav = predefinedValue.ToEntity(); - //locales - pav.Locales.Clear(); - var languages = await languageService.GetAllLanguages(true); - //localization - foreach (var lang in languages) - { - var name = predefinedValue.GetTranslation(x => x.Name, lang.Id, false); - if (!string.IsNullOrEmpty(name)) - pav.Locales.Add(new TranslationEntity - { LanguageId = lang.Id, LocaleKey = "Name", LocaleValue = name }); - } - - productAttributeMapping.ProductAttributeValues.Add(pav); - } - - await productAttributeService.InsertProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task UpdateProductAttributeMappingModel(ProductModel.ProductAttributeMappingModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product != null) - { - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping != null) - { - productAttributeMapping = model.ToEntity(productAttributeMapping); - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, - model.ProductId); - } - } - } - - public virtual async Task UpdateProductAttributeValidationRulesModel( - ProductAttributeMapping productAttributeMapping, ProductModel.ProductAttributeMappingModel model) - { - productAttributeMapping.ValidationMinLength = model.ValidationMinLength; - productAttributeMapping.ValidationMaxLength = model.ValidationMaxLength; - productAttributeMapping.ValidationFileAllowedExtensions = model.ValidationFileAllowedExtensions; - productAttributeMapping.ValidationFileMaximumSize = model.ValidationFileMaximumSize; - productAttributeMapping.DefaultValue = model.DefaultValue; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping) - { - var model = new ProductAttributeConditionModel { - ProductAttributeMappingId = productAttributeMapping.Id, - EnableCondition = productAttributeMapping.ConditionAttribute.Any(), - ProductId = product.Id - }; - //pre-select attribute and values - var selectedPva = product.ParseProductAttributeMappings(productAttributeMapping.ConditionAttribute) - .FirstOrDefault(); - - var attributes = product.ProductAttributeMappings - //ignore non-combinable attributes (should have selectable values) - .Where(x => x.CanBeUsedAsCondition()) - //ignore this attribute (it cannot depend on itself) - .Where(x => x.Id != productAttributeMapping.Id) - .ToList(); - foreach (var attribute in attributes) - { - var pam = await productAttributeService.GetProductAttributeById(attribute.ProductAttributeId); - var attributeModel = new ProductAttributeConditionModel.ProductAttributeModel { - Id = attribute.Id, - ProductAttributeId = attribute.ProductAttributeId, - Name = pam.Name, - TextPrompt = attribute.TextPrompt, - IsRequired = attribute.IsRequired, - AttributeControlType = attribute.AttributeControlTypeId - }; - - if (attribute.ShouldHaveValues()) - { - //values - var attributeValues = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == attribute.Id) - ?.ProductAttributeValues; - if (attributeValues != null) - foreach (var attributeValue in attributeValues) - { - var attributeValueModel = new ProductAttributeConditionModel.ProductAttributeValueModel { - Id = attributeValue.Id, - Name = attributeValue.Name, - IsPreSelected = attributeValue.IsPreSelected - }; - attributeModel.Values.Add(attributeValueModel); - } - - //pre-select attribute and value - if (selectedPva != null && attribute.Id == selectedPva.Id) - { - //attribute - model.SelectedProductAttributeId = selectedPva.Id; - - //values - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.Checkboxes: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - if (productAttributeMapping.ConditionAttribute.Any()) - { - //clear default selection - foreach (var item in attributeModel.Values) - item.IsPreSelected = false; - - //select new values - var selectedValues = - product.ParseProductAttributeValues(productAttributeMapping.ConditionAttribute); - foreach (var attributeValue in selectedValues) - foreach (var item in attributeModel.Values) - if (attributeValue.Id == item.Id) - item.IsPreSelected = true; - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - } - - model.ProductAttributes.Add(attributeModel); - } - - return model; - } - - public virtual async Task UpdateProductAttributeConditionModel(Product product, - ProductAttributeMapping productAttributeMapping, ProductAttributeConditionModel model) - { - var customAttributes = new List(); - if (model.EnableCondition) - { - var attribute = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.SelectedProductAttributeId); - if (attribute != null) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, ctrlAttributes).ToList(); - else - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - { - var anyValueSelected = false; - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - { - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, item).ToList(); - anyValueSelected = true; - } - - if (!anyValueSelected) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - else - { - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, "").ToList(); - } - } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - //these attribute types are supported as conditions - break; - } - } - - productAttributeMapping.ConditionAttribute = customAttributes; - await productAttributeService.UpdateProductAttributeMapping(productAttributeMapping, model.ProductId); - } - - public virtual async Task PrepareProductAttributeValueModel( - Product product, ProductAttributeMapping productAttributeMapping) - { - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = productAttributeMapping.Id, - ProductId = product.Id, - - //color squares - DisplayColorSquaresRgb = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ColorSquares, - ColorSquaresRgb = "#000000", - //image squares - DisplayImageSquaresPicture = - productAttributeMapping.AttributeControlTypeId == AttributeControlType.ImageSquares, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, - //default quantity for associated product - Quantity = 1 - }; - - //pictures - foreach (var x in product.ProductPictures) - model.ProductPictureModels.Add(new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = await pictureService.GetPictureUrl(x.PictureId), - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault - }); - - return model; - } - - public virtual async Task> PrepareProductAttributeValueModels( - Product product, ProductAttributeMapping productAttributeMapping) - { - var items = new List(); - foreach (var x in productAttributeMapping.ProductAttributeValues.OrderBy(x => x.DisplayOrder)) - { - Product associatedProduct = null; - if (x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct) - associatedProduct = await productService.GetProductById(x.AssociatedProductId); - - var pictureThumbnailUrl = await pictureService.GetPictureUrl( - string.IsNullOrEmpty(x.PictureId) ? x.ImageSquaresPictureId : x.PictureId, 100, false); - - if (string.IsNullOrEmpty(pictureThumbnailUrl)) - pictureThumbnailUrl = await pictureService.GetPictureUrl("", 1); - - items.Add(new ProductModel.ProductAttributeValueModel { - Id = x.Id, - ProductAttributeMappingId = productAttributeMapping.Id, //TODO - check x.ProductAttributeMappingId, - AttributeValueTypeId = x.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(x.AttributeValueTypeId), - AssociatedProductId = x.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = productAttributeMapping.AttributeControlTypeId != AttributeControlType.ColorSquares - ? x.Name - : $"{x.Name} - {x.ColorSquaresRgb}", - ColorSquaresRgb = x.ColorSquaresRgb, - ImageSquaresPictureId = x.ImageSquaresPictureId, - PriceAdjustment = x.PriceAdjustment, - PriceAdjustmentStr = x.PriceAdjustment.ToString("G29"), - WeightAdjustment = x.WeightAdjustment, - WeightAdjustmentStr = x.AttributeValueTypeId == AttributeValueType.Simple - ? x.WeightAdjustment.ToString("G29") - : "", - Cost = x.Cost, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId)) - ?.CurrencyCode, - Quantity = x.Quantity, - IsPreSelected = x.IsPreSelected, - DisplayOrder = x.DisplayOrder, - PictureId = x.PictureId, - PictureThumbnailUrl = pictureThumbnailUrl, - ProductId = product.Id - }); - } - - return items; - } - - public virtual async Task PrepareProductAttributeValueModel( - ProductAttributeMapping pa, ProductAttributeValue pav) - { - var associatedProduct = await productService.GetProductById(pav.AssociatedProductId); - - var model = new ProductModel.ProductAttributeValueModel { - ProductAttributeMappingId = pa.Id, //TODO - check pav.ProductAttributeMappingId, - AttributeValueTypeId = pav.AttributeValueTypeId, - AttributeValueTypeName = enumTranslationService.GetTranslationEnum(pav.AttributeValueTypeId), - AssociatedProductId = pav.AssociatedProductId, - AssociatedProductName = associatedProduct != null ? associatedProduct.Name : "", - Name = pav.Name, - ColorSquaresRgb = pav.ColorSquaresRgb, - DisplayColorSquaresRgb = pa.AttributeControlTypeId == AttributeControlType.ColorSquares, - ImageSquaresPictureId = pav.ImageSquaresPictureId, - DisplayImageSquaresPicture = pa.AttributeControlTypeId == AttributeControlType.ImageSquares, - PriceAdjustment = pav.PriceAdjustment, - WeightAdjustment = pav.WeightAdjustment, - Cost = pav.Cost, - PrimaryStoreCurrencyCode = - (await currencyService.GetCurrencyById(currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, - Quantity = pav.Quantity, - IsPreSelected = pav.IsPreSelected, - DisplayOrder = pav.DisplayOrder, - PictureId = pav.PictureId - }; - if (model.DisplayColorSquaresRgb && string.IsNullOrEmpty(model.ColorSquaresRgb)) - model.ColorSquaresRgb = "#000000"; - - return model; - } - - public virtual async Task InsertProductAttributeValueModel(ProductModel.ProductAttributeValueModel model) - { - var pav = new ProductAttributeValue { - AttributeValueTypeId = model.AttributeValueTypeId, - AssociatedProductId = model.AssociatedProductId, - Name = model.Name, - ColorSquaresRgb = model.ColorSquaresRgb, - ImageSquaresPictureId = model.ImageSquaresPictureId, - PriceAdjustment = model.PriceAdjustment, - WeightAdjustment = model.WeightAdjustment, - Cost = model.Cost, - Quantity = model.Quantity, - IsPreSelected = model.IsPreSelected, - DisplayOrder = model.DisplayOrder, - PictureId = model.PictureId, - Locales = model.Locales.ToTranslationProperty() - }; - await productAttributeService.InsertProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - - public virtual async Task UpdateProductAttributeValueModel(ProductAttributeValue pav, - ProductModel.ProductAttributeValueModel model) - { - pav.AttributeValueTypeId = model.AttributeValueTypeId; - pav.AssociatedProductId = model.AssociatedProductId; - pav.Name = model.Name; - pav.ColorSquaresRgb = model.ColorSquaresRgb; - pav.ImageSquaresPictureId = model.ImageSquaresPictureId; - pav.PriceAdjustment = model.PriceAdjustment; - pav.WeightAdjustment = model.WeightAdjustment; - pav.Cost = model.Cost; - pav.Quantity = model.Quantity; - pav.IsPreSelected = model.IsPreSelected; - pav.DisplayOrder = model.DisplayOrder; - pav.PictureId = model.PictureId; - pav.Locales = model.Locales.ToTranslationProperty(); - - await productAttributeService.UpdateProductAttributeValue(pav, model.ProductId, - model.ProductAttributeMappingId); - } - - public virtual Task - PrepareAssociateProductToAttributeValueModel() - { - var model = PrepareAddProductModel< - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel>(); - return Task.FromResult(model); - } - - public virtual async Task> - PrepareProductAttributeCombinationModel(Product product) - { - var items = new List(); - - foreach (var x in product.ProductAttributeCombinations) - { - var attributes = await productAttributeFormatter.FormatAttributes(product, x.Attributes, - contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false, true, true); - var pacModel = new ProductModel.ProductAttributeCombinationModel { - Id = x.Id, - ProductId = product.Id, - Attributes = string.IsNullOrEmpty(attributes) ? "(null)" : attributes, - StockQuantity = product.UseMultipleWarehouses - ? x.WarehouseInventory.Sum(y => y.StockQuantity - y.ReservedQuantity) - : x.StockQuantity, - AllowOutOfStockOrders = x.AllowOutOfStockOrders, - Sku = x.Sku, - Mpn = x.Mpn, - Gtin = x.Gtin, - OverriddenPrice = x.OverriddenPrice, - NotifyAdminForQuantityBelow = x.NotifyAdminForQuantityBelow - }; - items.Add(pacModel); - } - - return items; - } - - public virtual async Task PrepareProductAttributeCombinationModel( - Product product, string combinationId) - { - var model = new ProductAttributeCombinationModel(); - var wim = new List(); - foreach (var warehouse in await warehouseService.GetAllWarehouses()) - { - var pwiModel = new ProductAttributeCombinationModel.WarehouseInventoryModel { - WarehouseId = warehouse.Id, - WarehouseName = warehouse.Name - }; - wim.Add(pwiModel); - } - - if (product.UseMultipleWarehouses) - { - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - } - - if (!string.IsNullOrEmpty(combinationId)) - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == combinationId); - if (combination != null) - { - model = combination.ToModel(); - model.UseMultipleWarehouses = product.UseMultipleWarehouses; - model.WarehouseInventoryModels = wim; - model.ProductId = product.Id; - model.Attributes = await productAttributeFormatter.FormatAttributes(product, - combination.Attributes, contextAccessor.WorkContext.CurrentCustomer, "
", true, true, true, false); - if (model.UseMultipleWarehouses) - foreach (var winv in combination.WarehouseInventory) - { - var warehouseInventoryModel = - model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == winv.WarehouseId); - if (warehouseInventoryModel != null) - { - warehouseInventoryModel.WarehouseUsed = true; - warehouseInventoryModel.Id = winv.Id; - warehouseInventoryModel.StockQuantity = winv.StockQuantity; - warehouseInventoryModel.ReservedQuantity = winv.ReservedQuantity; - } - } - } - } - - return model; - } - - public virtual async Task> InsertOrUpdateProductAttributeCombinationPopup(Product product, - ProductAttributeCombinationModel model) - { - var customAttributes = new List(); - var warnings = new List(); - - async Task PrepareCombinationWarehouseInventory(ProductAttributeCombination combination) - { - var warehouses = await warehouseService.GetAllWarehouses(); - - foreach (var warehouse in warehouses) - { - var whim = model.WarehouseInventoryModels.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - var existingPwI = combination.WarehouseInventory.FirstOrDefault(x => x.WarehouseId == warehouse.Id); - if (existingPwI != null) - { - if (whim is { WarehouseUsed: true }) - { - //update - existingPwI.StockQuantity = whim.StockQuantity; - existingPwI.ReservedQuantity = whim.ReservedQuantity; - } - else - { - //delete - combination.WarehouseInventory.Remove(existingPwI); - } - } - else - { - if (whim is { WarehouseUsed: true }) - { - //no need to insert a record for qty 0 - existingPwI = new ProductCombinationWarehouseInventory { - WarehouseId = whim.WarehouseId, - StockQuantity = whim.StockQuantity, - ReservedQuantity = whim.ReservedQuantity - }; - combination.WarehouseInventory.Add(existingPwI); - } - } - } - } - - if (string.IsNullOrEmpty(model.Id)) - { - #region Product attributes - - var attributes = product.ProductAttributeMappings - .Where(x => !x.IsNonCombinable()) - .ToList(); - if (attributes.Count == 0) - { - warnings.Add("This combination attributes is empty!"); - return warnings; - } - - foreach (var attribute in attributes) - switch (attribute.AttributeControlTypeId) - { - case AttributeControlType.DropdownList: - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - var ctrlAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(ctrlAttributes)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, ctrlAttributes).ToList(); - } - break; - case AttributeControlType.Checkboxes: - { - var cblAttributes = model.SelectedAttributes.FirstOrDefault(x => x.Key == attribute.Id) - ?.Value; - if (!string.IsNullOrEmpty(cblAttributes)) - foreach (var item in cblAttributes.Split([','], - StringSplitOptions.RemoveEmptyEntries)) - if (!string.IsNullOrEmpty(item)) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, item).ToList(); - } - break; - case AttributeControlType.ReadonlyCheckboxes: - { - //load read-only (already server-side selected) values - var attributeValues = attribute.ProductAttributeValues; - foreach (var selectedAttributeId in attributeValues - .Where(v => v.IsPreSelected) - .Select(v => v.Id) - .ToList()) - customAttributes = ProductExtensions.AddProductAttribute( - customAttributes, - attribute, selectedAttributeId).ToList(); - } - break; - } - - //validate conditional attributes (if specified) - foreach (var attribute in attributes) - { - var conditionMet = product.IsConditionMet(attribute, customAttributes); - if (conditionMet.HasValue && !conditionMet.Value) - customAttributes = ProductExtensions - .RemoveProductAttribute(customAttributes, attribute).ToList(); - } - - if (customAttributes.Count == 0) - { - warnings.Add("This combination custom attributes is empty!"); - return warnings; - } - - foreach (var customAttribute in customAttributes) - if (string.IsNullOrEmpty(customAttribute.Value)) - { - warnings.Add("Combination custom attributes need to be selected value!"); - return warnings; - } - - #endregion - - if (product.FindProductAttributeCombination(customAttributes) != null) - warnings.Add("This combination attributes exists!"); - - if (warnings.Count == 0) - { - var combination = new ProductAttributeCombination { - Attributes = customAttributes, - StockQuantity = model.StockQuantity, - ReservedQuantity = model.ReservedQuantity, - AllowOutOfStockOrders = model.AllowOutOfStockOrders, - Sku = model.Sku, - Text = model.Text, - Mpn = model.Mpn, - Gtin = model.Gtin, - OverriddenPrice = model.OverriddenPrice, - NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow, - PictureId = model.PictureId - }; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - combination.ReservedQuantity = combination.WarehouseInventory.Sum(x => x.ReservedQuantity); - } - - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - } - else - { - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == model.Id); - var prevCombination = (ProductAttributeCombination)combination!.Clone(); - - combination.StockQuantity = model.StockQuantity; - combination.ReservedQuantity = model.ReservedQuantity; - combination.AllowOutOfStockOrders = model.AllowOutOfStockOrders; - combination.Sku = model.Sku; - combination.Text = model.Text; - combination.Mpn = model.Mpn; - combination.Gtin = model.Gtin; - combination.OverriddenPrice = model.OverriddenPrice; - combination.NotifyAdminForQuantityBelow = model.NotifyAdminForQuantityBelow; - combination.PictureId = model.PictureId; - - if (product.UseMultipleWarehouses) - { - await PrepareCombinationWarehouseInventory(combination); - combination.StockQuantity = combination.WarehouseInventory.Sum(x => x.StockQuantity); - } - - //notification - out of stock - await OutOfStockNotifications(product, combination, prevCombination); - - //update combination - await productAttributeService.UpdateProductAttributeCombination(combination, product.Id); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await productService.GetProductById(model.ProductId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); - } - } - - return warnings; - } - - public virtual async Task GenerateAllAttributeCombinations(Product product) - { - var allAttributesComb = product.GenerateAllCombinations(); - if (allAttributesComb == null || allAttributesComb.Count == 0) - return; - - foreach (var attributes in allAttributesComb) - { - var customAttributes = attributes.ToList(); - if (!customAttributes.Any()) - continue; - - var existingCombination = product.FindProductAttributeCombination(customAttributes); - - //already exists? - if (existingCombination != null) - continue; - - //save combination - var combination = new ProductAttributeCombination { - Attributes = customAttributes.ToList(), - StockQuantity = 0, - AllowOutOfStockOrders = false, - Sku = null, - Mpn = null, - Gtin = null, - OverriddenPrice = null, - NotifyAdminForQuantityBelow = 1 - }; - await productAttributeService.InsertProductAttributeCombination(combination, product.Id); - } - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = product.ProductAttributeCombinations.Sum(x => x.StockQuantity); - product.ReservedQuantity = product.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(product, false); - } - } - - public virtual async Task ClearAllAttributeCombinations(Product product) - { - foreach (var combination in product.ProductAttributeCombinations) - await productAttributeService.DeleteProductAttributeCombination(combination, product.Id); - } - - public virtual Task> - PrepareProductAttributeCombinationTierPricesModel(Product product, string productAttributeCombinationId) - { - var items = new List(); - foreach (var x in product.ProductAttributeCombinations.Where(x => x.Id == productAttributeCombinationId) - .SelectMany(x => x.TierPrices)) - { - var priceModel = new ProductModel.ProductAttributeCombinationTierPricesModel { - Id = x.Id, - Price = x.Price, - Quantity = x.Quantity, - ProductId = product.Id, - ProductAttributeCombinationId = productAttributeCombinationId - }; - items.Add(priceModel); - } - - return Task.FromResult>(items); - } - - public virtual async Task InsertProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (productAttributeCombination != null) - { - var pctp = new ProductCombinationTierPrices { - Price = model.Price, - Quantity = model.Quantity - }; - productAttributeCombination.TierPrices.Add(pctp); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, - product.Id); - } - } - - public virtual async Task UpdateProductAttributeCombinationTierPricesModel(Product product, - ProductAttributeCombination productAttributeCombination, - ProductModel.ProductAttributeCombinationTierPricesModel model) - { - if (productAttributeCombination != null) - { - var tierPrice = productAttributeCombination.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice != null) - { - tierPrice.Price = model.Price; - tierPrice.Quantity = model.Quantity; - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, - product.Id); - } - } - } - - public virtual async Task DeleteProductAttributeCombinationTierPrices(Product product, - ProductAttributeCombination productAttributeCombination, ProductCombinationTierPrices tierPrice) - { - productAttributeCombination.TierPrices.Remove(tierPrice); - await productAttributeService.UpdateProductAttributeCombination(productAttributeCombination, product.Id); - } - - //Pictures - public virtual async Task> PrepareProductPicturesModel(Product product) - { - var items = new List(); - foreach (var x in product.ProductPictures.OrderBy(x => x.DisplayOrder)) - { - var picture = await pictureService.GetPictureById(x.PictureId); - var m = new ProductModel.ProductPictureModel { - Id = x.Id, - ProductId = product.Id, - PictureId = x.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = x.DisplayOrder, - IsDefault = x.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - items.Add(m); - } - - return items; - } - - public virtual async Task<(ProductModel.ProductPictureModel model, Picture Picture)> PrepareProductPictureModel( - Product product, ProductPicture productPicture) - { - var picture = await pictureService.GetPictureById(productPicture.PictureId); - var model = new ProductModel.ProductPictureModel { - Id = productPicture.Id, - ProductId = product.Id, - PictureId = productPicture.PictureId, - PictureUrl = picture != null ? await pictureService.GetPictureUrl(picture) : null, - AltAttribute = picture?.AltAttribute, - TitleAttribute = picture?.TitleAttribute, - DisplayOrder = productPicture.DisplayOrder, - IsDefault = productPicture.IsDefault, - Style = picture?.Style, - ExtraField = picture?.ExtraField - }; - - return (model, picture); - } - - public virtual async Task InsertProductPicture(Product product, Picture picture, int displayOrder) - { - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - if (product.ProductPictures.Count(x => x.PictureId == picture.Id) > 0) - return; - - await productService.InsertProductPicture(new ProductPicture { - PictureId = picture.Id, - DisplayOrder = displayOrder, - IsDefault = product.ProductPictures.Any() - }, product.Id); - - await pictureService.SetSeoFilename(picture, pictureService.GetPictureSeName(product.Name)); - } - - public virtual async Task UpdateProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var picture = await pictureService.GetPictureById(productPicture.PictureId); - if (picture == null) - throw new ArgumentException("No picture found with the specified id"); - - productPicture.DisplayOrder = model.DisplayOrder; - productPicture.IsDefault = model.IsDefault; - await productService.UpdateProductPicture(productPicture, product.Id); - - //Update picture fields - await pictureService.UpdatePictureField(picture, x => x.AltAttribute, model.AltAttribute); - await pictureService.UpdatePictureField(picture, x => x.TitleAttribute, model.TitleAttribute); - await pictureService.UpdatePictureField(picture, x => x.Locales, model.Locales.ToTranslationProperty()); - await pictureService.UpdatePictureField(picture, x => x.Style, model.Style); - await pictureService.UpdatePictureField(picture, x => x.ExtraField, model.ExtraField); - } - - public virtual async Task DeleteProductPicture(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId, true); - - var productPicture = product.ProductPictures.FirstOrDefault(x => x.Id == model.Id); - if (productPicture == null) - throw new ArgumentException("No product picture found with the specified id"); - - var pictureId = productPicture.PictureId; - await productService.DeleteProductPicture(productPicture, product.Id); - - var picture = await pictureService.GetPictureById(pictureId); - if (picture != null) - await pictureService.DeletePicture(picture); - } - - //Product specification - public virtual async Task> PrepareProductSpecificationAttributeModel( - Product product) - { - var items = new List(); - foreach (var x in product.ProductSpecificationAttributes.OrderBy(x => x.DisplayOrder)) - { - var psaModel = new ProductSpecificationAttributeModel { - Id = x.Id, - AttributeTypeId = (int)x.AttributeTypeId, - AttributeId = x.SpecificationAttributeId, - ProductId = product.Id, - AttributeTypeName = enumTranslationService.GetTranslationEnum(x.AttributeTypeId), - AllowFiltering = x.AllowFiltering, - ShowOnProductPage = x.ShowOnProductPage, - DisplayOrder = x.DisplayOrder, - AttributeName = x.CustomName - }; - - switch (x.AttributeTypeId) - { - case SpecificationAttributeType.Option: - var specificationAttribute = - await specificationAttributeService.GetSpecificationAttributeById( - x.SpecificationAttributeId); - if (specificationAttribute != null) - { - psaModel.AttributeName = specificationAttribute.Name; - psaModel.ValueRaw = WebUtility.HtmlEncode(specificationAttribute - .SpecificationAttributeOptions - .FirstOrDefault(y => y.Id == x.SpecificationAttributeOptionId)?.Name); - } - - psaModel.SpecificationAttributeOptionId = x.SpecificationAttributeOptionId; - break; - case SpecificationAttributeType.CustomText: - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.CustomHtmlText: - //do not encode? - psaModel.ValueRaw = WebUtility.HtmlEncode(x.CustomValue); - break; - case SpecificationAttributeType.Hyperlink: - psaModel.ValueRaw = x.CustomValue; - break; - } - - items.Add(psaModel); - } - - return items; - } - - public virtual async Task InsertProductSpecificationAttributeModel( - ProductModel.AddProductSpecificationAttributeModel model, Product product) - { - //we allow filtering only for "Option" attribute type - if (model.AttributeTypeId != (int)SpecificationAttributeType.Option) - { - model.AllowFiltering = false; - model.SpecificationAttributeOptionId = null; - } - - var psa = model.ToEntity(); - - await specificationAttributeService.InsertProductSpecificationAttribute(psa, product.Id); - product.ProductSpecificationAttributes.Add(psa); - } - - public virtual async Task UpdateProductSpecificationAttributeModel(ProductSpecificationAttribute psa, - ProductModel.AddProductSpecificationAttributeModel model) - { - psa = model.ToEntity(psa); - await specificationAttributeService.UpdateProductSpecificationAttribute(psa, model.ProductId); - } - - public virtual async Task DeleteProductSpecificationAttribute(Product product, - ProductSpecificationAttribute psa) - { - product.ProductSpecificationAttributes.Remove(psa); - await specificationAttributeService.DeleteProductSpecificationAttribute(psa, product.Id); - } - - protected virtual async Task UpdatePictureSeoNames(Product product) - { - var picturesename = pictureService.GetPictureSeName(product.Name); - foreach (var pp in product.ProductPictures) - { - var picture = await pictureService.GetPictureById(pp.PictureId); - if (picture != null) - await pictureService.SetSeoFilename(picture, picturesename); - } - } - - protected virtual async Task> GetChildCategoryIds(string parentCategoryId) - { - var categoriesIds = new List(); - var categories = await categoryService.GetAllCategoriesByParentCategoryId(parentCategoryId, true); - foreach (var category in categories) - { - categoriesIds.Add(category.Id); - categoriesIds.AddRange(await GetChildCategoryIds(category.Id)); - } - - return categoriesIds; - } - - protected virtual T PrepareAddProductModel() where T : ProductModel.AddProductModel, new() - { - var model = new T { - //product types - AvailableProductTypes = enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList() - }; - - model.AvailableProductTypes.Insert(0, - new SelectListItem { Text = translationService.GetResource("Vendor.Common.All"), Value = "0" }); - - return model; - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs index f8759d4ec2..510afb9ef8 100644 --- a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs @@ -14,7 +14,10 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config return; services.AddScoped, Grand.Web.AdminShared.Services.VendorProductDataScope>(); - services.AddScoped(); + // IProductViewModelService is registered by Grand.Web.AdminShared's own StartupApplication + // (Grand.Web.AdminShared/Startup/StartupApplication.cs), which is discovered and run for this + // host too via the IStartupApplication assembly scan in StartupBase, since Vendor references + // AdminShared. Registering it again here would just be a redundant duplicate of that line. services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs deleted file mode 100644 index 6f8ec992e4..0000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/AddProductSpecificationAttributeModelValidator.cs +++ /dev/null @@ -1,38 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class AddProductSpecificationAttributeModelValidator : BaseGrandValidator< - ProductModel.AddProductSpecificationAttributeModel> -{ - public AddProductSpecificationAttributeModelValidator( - IEnumerable> validators, - ITranslationService translationService, - ISpecificationAttributeService specificationAttributeService) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - if (x.AttributeTypeId == SpecificationAttributeType.Option) - { - if (string.IsNullOrEmpty(x.SpecificationAttributeId)) - return false; - if (string.IsNullOrEmpty(x.SpecificationAttributeOptionId)) - return false; - - var specification = - await specificationAttributeService.GetSpecificationAttributeById(x.SpecificationAttributeId); - - return specification?.SpecificationAttributeOptions.FirstOrDefault(z => - z.Id == x.SpecificationAttributeOptionId) != null; - } - - return !string.IsNullOrEmpty(x.CustomValue); - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.SpecificationAttributes.Validate")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs deleted file mode 100644 index 742983a338..0000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/BundleProductModelValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Infrastructure; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class BundleProductModelValidator : BaseGrandValidator -{ - public BundleProductModelValidator( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - var product = await productService.GetProductById(x.ProductBundleId); - if (product == null) return true; - return product.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs deleted file mode 100644 index 24f055c902..0000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductAttributeValueModelValidator.cs +++ /dev/null @@ -1,49 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductAttributeValueModelValidator : BaseGrandValidator -{ - public ProductAttributeValueModelValidator( - IEnumerable> validators, - ITranslationService translationService, IProductService productService) - : base(validators) - { - RuleFor(x => x.Name) - .NotEmpty() - .WithMessage(translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name.Required")); - - RuleFor(x => x.Quantity) - .GreaterThanOrEqualTo(1) - .WithMessage(translationService.GetResource( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Quantity.GreaterThanOrEqualTo1")) - .When(x => x.AttributeValueTypeId == AttributeValueType.AssociatedToProduct); - - RuleFor(x => x).CustomAsync(async (x, context, _) => - { - var product = await productService.GetProductById(x.ProductId); - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(y => y.Id == x.ProductAttributeMappingId); - switch (productAttributeMapping?.AttributeControlTypeId) - { - case AttributeControlType.ColorSquares: - { - //ensure valid color is chosen/entered - if (string.IsNullOrEmpty(x.ColorSquaresRgb)) - context.AddFailure("Color is required"); - break; - } - //ensure a picture is uploaded - case AttributeControlType.ImageSquares when string.IsNullOrEmpty(x.ImageSquaresPictureId): - context.AddFailure("Image is required"); - break; - } - }); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs deleted file mode 100644 index 26f388870d..0000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidVendor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Infrastructure; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductValidVendor : BaseGrandValidator -{ - public ProductValidVendor( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - var product = await productService.GetProductById(x.ProductId); - if (product == null) return true; - return product.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} - -public class ProductRelatedValidVendor : BaseGrandValidator -{ - public ProductRelatedValidVendor( - IEnumerable> validators, - ITranslationService translationService, IProductService productService, IContextAccessor contextAccessor) - : base(validators) - { - RuleFor(x => x).MustAsync(async (x, _, _) => - { - //RelatedProductModel/SimilarProductModel actions only ever read and mutate ProductId1's - //mapping list, so ownership of ProductId1 is what must be enforced here. Accepting ownership - //of ProductId2 as an alternative (the previous "||") let a vendor who owns any product supply - //it as ProductId2 and edit/delete another vendor's ProductId1 mapping. - var product1 = await productService.GetProductById(x.ProductId1); - if (product1 == null) return true; - return product1.VendorId == contextAccessor.WorkContext.CurrentVendor.Id; - }).WithMessage(translationService.GetResource("Vendor.Catalog.Products.Permissions")); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs b/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs deleted file mode 100644 index e375299187..0000000000 --- a/src/Web/Grand.Web.Vendor/Validators/Catalog/ProductValidator.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Domain.Catalog; -using Grand.Domain.Common; -using Grand.Infrastructure.Validators; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Validators.Catalog; - -public class ProductValidator : BaseGrandValidator -{ - public ProductValidator( - IEnumerable> validators, - ITranslationService translationService, CommonSettings commonSettings) - : base(validators) - { - RuleFor(x => x.Name).NotEmpty() - .WithMessage(translationService.GetResource("Vendor.Catalog.Products.Fields.Name.Required")); - if (!commonSettings.AllowEditProductEndedAuction) - RuleFor(x => x.AuctionEnded && x.ProductTypeId == (int)ProductType.Auction).Equal(false) - .WithMessage(translationService.GetResource("Admin.Catalog.Products.Cannoteditauction")); - - RuleFor(x => x.ProductTypeId == (int)ProductType.Auction && !x.AvailableEndDateTime.HasValue) - .Equal(false) - .WithMessage( - translationService.GetResource("Vendor.Catalog.Products.Fields.AvailableEndDateTime.Required")); - } -} \ No newline at end of file From b521a35f1f498e96ea6e535a3ec853e7be445c38 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 16:42:17 +0200 Subject: [PATCH 056/147] Task 12 fix round 1: correct stale doc comment, escalate review's orphaned-file findings into Task 13's plan Review (opus) PASS on spec compliance and code quality, no Critical/High findings. Addresses 3 of its 5 Low/Informational findings that were cheap and actionable now: 1. Grand.Web.Vendor/Controllers/ProductController.cs's doc comment still said 'NOT wired into DI yet... until Task 12' - now stale/misleading since this commit IS Task 12. Corrected to describe the actual (working) DI resolution path. 2. Task 13's plan section updated: Admin/Store's per-host ProductControllerTests.cs trim is already done (Task 11), only Vendor's remains - noted so a future implementer doesn't redo settled work. 3. Two newly-discovered orphaned-file chains added as explicit Task 13 deletions: Grand.Web.Vendor/Models/Catalog/*.cs + Mapper/ProductProfile.cs + Extensions/ProductsMappingExtensions.cs (confirmed dead by the review, but ProductsMappingExtensions.cs's ToModel/ToEntity methods would silently shadow if ever called again), plus Grand.Mapping.Tests/Vendor/VendorMappingTests.cs which also binds the doomed namespace and will break once those are deleted - the review found this file was missing from the original Task 13 handoff list. Remaining 2 findings (resource-key-prefix mechanism inconsistency between AdminShared's validators and BaseProductController; read-only AddPopupList actions lacking scope.HasAccess, which is Task 8/11's established design not a new gap) parked in the ledger as accurate but non-blocking. --- ...6-08-16-arch001-product-consolidation-phase1.md | 14 ++++++++++++-- .../Controllers/ProductController.cs | 10 ++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md index 4604e77e1c..f507ce9504 100644 --- a/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md +++ b/docs/superpowers/plans/2026-08-16-arch001-product-consolidation-phase1.md @@ -1429,9 +1429,19 @@ git commit -m "Delete Vendor's duplicate ProductViewModelService/interface, use ## Task 13: Consolidate characterization tests, delete superseded per-host duplicates +**Status update (added after Task 11/12's reviews):** Admin's and Store's per-host +`ProductControllerTests.cs` were already fully deleted during Task 11 (they no longer compiled against +the new thin-subclass constructor) and replaced with routing/attribute-only coverage +(`ProductControllerAttributesTests.cs` in each of `Grand.Web.Admin.Tests`/`Grand.Web.Store.Tests`, plus +`Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs` for the `EditWarningCheck` hook) — Steps +1-2 below are already done for those two hosts; only Vendor's equivalent remains. + +**Additional files, added after Task 12's review (opus) found them:** +- Delete: `src/Web/Grand.Web.Vendor/Models/Catalog/*.cs` (ProductModel.cs, ProductAttributeCombinationModel.cs, ProductAttributeConditionModel.cs, ProductSpecificationAttributeModel.cs, IProductValidVendor.cs, BulkEditListModel.cs, BulkEditProductModel.cs, CopyProductModel.cs, ProductAttributeModel.cs, ProductListModel.cs, ProductReviewModel.cs) — orphaned since Task 12 repointed `_ViewImports.cshtml` to AdminShared's models; confirmed nothing in `src/Web` still binds them except the files below. +- Delete: `src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs`, `src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs` — also orphaned; the latter defines `ToModel`/`ToEntity` extension methods returning the dead Vendor models, which would silently shadow if anything ever called them again, so delete rather than leave as inert. +- Modify: `src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs` — also binds `Grand.Web.Vendor.Models.Catalog`; will fail to compile once the above are deleted. Trim/delete its Product-related cases the same way as the controller test files, or delete the file outright if nothing else in it survives. + **Files:** -- Modify/trim: `src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTests.cs` -- Modify/trim: `src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs` - Modify/trim: `src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs` - Delete: `src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs` (superseded by `Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs`, which now covers all scope variants per Tasks 9-10) diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 3ff638099e..dc6ba46915 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -23,12 +23,10 @@ namespace Grand.Web.Vendor.Controllers; // so each subclass restates its own host's attribute set explicitly. No EditWarningCheck override // needed - Vendor's original had no equivalent branch. // -// NOT wired into DI yet: this file compiles fine (it references AdminShared's IProductViewModelService -// directly), but Vendor's DI container still only registers its own old, duplicate -// Grand.Web.Vendor.Interfaces.IProductViewModelService/ProductViewModelService - nothing registers -// AdminShared's IProductViewModelService for Vendor yet, so this constructor cannot be resolved at -// runtime until Task 12 deletes Vendor's duplicate and rewires DI to AdminShared's implementation (see -// Task 11's plan Step 4). Left as an uncommitted working-tree change per plan Step 5 until Task 12. +// DI wiring: resolves via AdminShared's own IStartupApplication (Priority 101), which registers +// IProductViewModelService for any host referencing the AdminShared assembly - including Vendor. +// Vendor's own duplicate registration was removed in Task 12, alongside the duplicate service/interface +// it pointed at. [AutoValidateAntiforgeryToken] [Area(Constants.AreaVendor)] [AuthorizeVendor] From 26888acb247c8f495c4fab1d3a9a050d4cce2ccb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:07:20 +0200 Subject: [PATCH 057/147] Trim Vendor ProductControllerTests to routing-only, delete superseded ProductViewModelServiceTests (ARCH-001 Phase 1) Vendor's ProductControllerTests.cs referenced the old deleted IProductViewModelService/ constructor arity; all its scope/access-check cases (Delete/Edit denial for products owned by another vendor) are now covered by BaseProductControllerTests, parameterized over a mocked IAdminDataScope including Vendor's own HasAccess semantics. Replaced with an attribute-only smoke test matching Admin's/Store's ProductControllerAttributesTests.cs pattern. Vendor's Services/ProductViewModelServiceTests.cs referenced the deleted Grand.Web.Vendor.Services.ProductViewModelService class directly (removed in Task 12). All its cases - vendor-forced InsertProductModel, vendor-filtered PrepareProducts, ownership-filtered DeleteSelected/Insert*ProductModel, including the InsertSimilarProductModel HasAccessToProduct(candidate)-not-source regression guard - are already covered by Grand.Web.Admin.Tests/Services/ProductViewModelServiceTests.cs via the mocked IAdminDataScope's Vendor-scope variants. Co-Authored-By: Claude Sonnet 5 --- ...buteCombination_ToVendorModel.verified.txt | 8 - ...ttributeMapping_ToVendorModel.verified.txt | 12 - ....Product_ToVendorProductModel.verified.txt | 57 - ....VendorProductModel_ToProduct.verified.txt | 44 - .../Controllers/ProductControllerTests.cs | 156 +-- .../Services/ProductViewModelServiceTests.cs | 227 ---- .../Extensions/ProductsMappingExtensions.cs | 118 --- .../Grand.Web.Vendor/Mapper/ProductProfile.cs | 102 -- .../Models/Catalog/BulkEditListModel.cs | 29 - .../Models/Catalog/BulkEditProductModel.cs | 33 - .../Models/Catalog/CopyProductModel.cs | 16 - .../Models/Catalog/IProductValidVendor.cs | 32 - .../ProductAttributeCombinationModel.cs | 111 -- .../Catalog/ProductAttributeConditionModel.cs | 51 - .../Models/Catalog/ProductAttributeModel.cs | 64 -- .../Models/Catalog/ProductListModel.cs | 44 - .../Models/Catalog/ProductModel.cs | 992 ------------------ .../Models/Catalog/ProductReviewModel.cs | 44 - .../ProductSpecificationAttributeModel.cs | 26 - 19 files changed, 31 insertions(+), 2135 deletions(-) delete mode 100644 src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt delete mode 100644 src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt delete mode 100644 src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt delete mode 100644 src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt delete mode 100644 src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs delete mode 100644 src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs delete mode 100644 src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs delete mode 100644 src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt deleted file mode 100644 index 8d4ae37e47..0000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeCombination_ToVendorModel.verified.txt +++ /dev/null @@ -1,8 +0,0 @@ -{ - Id: pac-v-1, - StockQuantity: 5, - AllowOutOfStockOrders: false, - Sku: COMB-SKU, - NotifyAdminForQuantityBelow: 1, - UseMultipleWarehouses: false -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt deleted file mode 100644 index 2e5ae0370f..0000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.ProductAttributeMapping_ToVendorModel.verified.txt +++ /dev/null @@ -1,12 +0,0 @@ -{ - ProductAttributeId: pa-1, - TextPrompt: Choose size, - IsRequired: true, - ShowOnCatalogPage: false, - AttributeControlTypeId: DropdownList, - Combination: false, - ShouldHaveValues: false, - ValidationRulesAllowed: false, - ConditionAllowed: false, - Id: pam-v-1 -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt deleted file mode 100644 index b7826ea540..0000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.Product_ToVendorProductModel.verified.txt +++ /dev/null @@ -1,57 +0,0 @@ -{ - Id: prod-v-1, - ProductTypeId: 5, - AuctionEnded: false, - VisibleIndividually: false, - Name: Vendor Product, - ShortDescription: Short desc, - FullDescription: Full description, - AllowCustomerReviews: false, - Sku: VSKU001, - IsGiftVoucher: false, - RequireOtherProducts: false, - AutoAddRequiredProducts: false, - IsRecurring: false, - CalendarModel: { - Interval: 1, - IncBothDate: false, - Quantity: 1, - Monday: false, - Tuesday: false, - Wednesday: false, - Thursday: false, - Friday: false, - Saturday: false, - Sunday: false - }, - IsShipEnabled: false, - IsFreeShipping: false, - ShipSeparately: false, - IsTaxExempt: false, - IsTele: false, - UseMultipleWarehouses: false, - StockQuantity: 50, - StockAvailability: false, - DisplayStockQuantity: false, - AllowOutOfStockSubscriptions: false, - NotReturnable: false, - DisableBuyButton: false, - DisableWishlistButton: false, - AvailableForPreOrder: false, - CallForPrice: false, - Price: 49.99, - OldPrice: 59.99, - EnteredPrice: false, - BasepriceEnabled: false, - MarkAsNew: false, - Weight: 1.5, - DisplayOrder: 1, - Published: true, - AddPictureModel: { - IsDefault: false - }, - CopyProductModel: { - CopyImages: false, - Published: false - } -} \ No newline at end of file diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt deleted file mode 100644 index 0cb803d5f4..0000000000 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.VendorProductModel_ToProduct.verified.txt +++ /dev/null @@ -1,44 +0,0 @@ -{ - VisibleIndividually: false, - Name: New Product, - ShortDescription: Short, - FullDescription: Full, - ShowOnHomePage: false, - BestSeller: false, - AllowCustomerReviews: false, - Sku: SKU002, - IsGiftVoucher: false, - RequireOtherProducts: false, - AutoAddRequiredProducts: false, - IsDownload: false, - UnlimitedDownloads: false, - HasSampleDownload: false, - HasUserAgreement: false, - IsRecurring: false, - IncBothDate: false, - IsShipEnabled: false, - IsFreeShipping: false, - ShipSeparately: false, - IsTaxExempt: false, - IsTele: false, - UseMultipleWarehouses: false, - StockQuantity: 25, - StockAvailability: false, - DisplayStockQuantity: false, - LowStock: false, - AllowOutOfStockSubscriptions: false, - NotReturnable: false, - DisableBuyButton: false, - DisableWishlistButton: false, - AvailableForPreOrder: false, - CallForPrice: false, - Price: 39.99, - EnteredPrice: false, - BasepriceEnabled: false, - MarkAsNew: false, - AuctionEnded: false, - Published: true, - LimitedToGroups: false, - LimitedToStores: false, - Id: ObjectId_1 -} \ No newline at end of file diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs index ea727905a1..bad6c67653 100644 --- a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs @@ -1,138 +1,44 @@ -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Security; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Catalog; -using Grand.Domain.Vendors; -using Grand.Infrastructure; -using Grand.Web.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Controllers; -using Grand.Web.Vendor.Interfaces; -using Grand.Web.Vendor.Models.Catalog; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; namespace Grand.Web.Vendor.Tests.Controllers; -// Characterization tests for the tenant-isolation checks in ProductController, ahead of the planned -// consolidation of the near-duplicate ProductController/ProductViewModelService copies in -// Grand.Web.Admin / Grand.Web.Store / Grand.Web.Vendor. These lock down the *current* behavior -// (including the redirect target chosen on access denial) so the refactor has something to fail against. +// Regression lock for the missing-authorization-attribute bug caught during ARCH-001 Phase 1 Task 11: +// the plan's own inline example code for the thin ProductController subclass omitted +// [AuthorizeVendor]/[AutoValidateAntiforgeryToken]/[AuthorizeMenu] entirely, because BaseProductController +// can't inherit any single host's base controller and so those attributes no longer arrive +// transitively. Following the plan literally would have shipped Vendor's product management with no +// CSRF protection and no authentication/authorization filter. This test makes that class of regression +// fail loudly instead of silently the next time this controller (or one like it) is touched. +// +// All scope/access-check behavior (including Vendor's own HasAccess semantics) is now covered by +// BaseProductControllerTests, parameterized over a mocked IAdminDataScope - see +// Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs. This file only keeps routing/ +// attribute-only coverage, mirroring Admin's and Store's ProductControllerAttributesTests.cs. [TestClass] public class ProductControllerTests { - private const string OwnVendorId = "vendor-1"; - private const string OtherVendorId = "vendor-2"; - - private ProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - - [TestInitialize] - public void Setup() - { - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentVendor).Returns(new Domain.Vendors.Vendor { Id = OwnVendorId }); - var contextAccessorMock = new Mock(); - contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); - - _controller = new ProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - contextAccessorMock.Object, - new Mock().Object, - _translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object); - - var httpContext = new DefaultHttpContext(); - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } - - [TestMethod] - public async Task Delete_ProductNotFound_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", true)).ReturnsAsync((Product)null); - - var result = await _controller.Delete("missing"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOwnedByAnotherVendor_RedirectsToListWithoutDeleting() - { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Never); - } - - [TestMethod] - public async Task Delete_ProductOwnedByCurrentVendor_DeletesAndRedirectsToList() - { - var product = new Product { Id = "p1", VendorId = OwnVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Delete("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify(s => s.DeleteProduct(product), Times.Once); - } - [TestMethod] - public async Task EditGet_ProductOwnedByAnotherVendor_RedirectsToListWithoutPreparingModel() + public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit("p1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.PrepareProductModel(It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task EditPost_ProductOwnedByAnotherVendor_RedirectsToListWithoutUpdating() - { - var product = new Product { Id = "p1", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - - var result = await _controller.Edit(new ProductModel { Id = "p1" }, continueEditing: false); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _productViewModelServiceMock.Verify( - s => s.UpdateProductModel(It.IsAny(), It.IsAny()), Times.Never); + var type = typeof(ProductController); + + Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), "Missing [AuthorizeVendor]."); + Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), + "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + + // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up + // from the base class without the subclass needing to restate it. + var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, + typeof(PermissionAuthorizeAttribute), true); + Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs deleted file mode 100644 index a6306550db..0000000000 --- a/src/Tests/Grand.Web.Vendor.Tests/Services/ProductViewModelServiceTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Grand.Business.Core.Interfaces.Catalog.Categories; -using Grand.Business.Core.Interfaces.Catalog.Collections; -using Grand.Business.Core.Interfaces.Catalog.Directory; -using Grand.Business.Core.Interfaces.Catalog.Prices; -using Grand.Business.Core.Interfaces.Catalog.Products; -using Grand.Business.Core.Interfaces.Catalog.Tax; -using Grand.Business.Core.Interfaces.Checkout.Shipping; -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Seo; -using Grand.Business.Core.Interfaces.Common.Stores; -using Grand.Business.Core.Interfaces.Customers; -using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain; -using Grand.Domain.Catalog; -using Grand.Domain.Directory; -using Grand.Domain.Tax; -using Grand.Domain.Vendors; -using Grand.Infrastructure; -using Grand.Infrastructure.Mapper; -using Grand.Mapping; -using Grand.Web.Common.Localization; -using Grand.Web.Vendor.Mapper; -using Grand.Web.Vendor.Models.Catalog; -using Grand.Web.Vendor.Services; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; - -namespace Grand.Web.Vendor.Tests.Services; - -// Characterization tests for the vendor-specific behavior of this forked ProductViewModelService, ahead -// of the planned consolidation back into Grand.Web.AdminShared.Services.ProductViewModelService (the two -// classes are ~85% identical; the remainder is the tenant-isolation logic covered here). These tests must -// keep passing (or have their expectation deliberately revised in the same change) once the fork is -// removed and the AdminShared implementation is parameterized/extended for the Vendor area instead. -[TestClass] -public class ProductViewModelServiceTests -{ - private const string CurrentVendorId = "vendor-1"; - private const string OtherVendorId = "vendor-2"; - - private Mock _productServiceMock; - private Mock _seNameServiceMock; - private ProductViewModelService _service; - - [TestInitialize] - public void Setup() - { - var mapperConfig = new MapperConfiguration(cfg => { cfg.AddProfile(); }); - AutoMapperConfig.Init(mapperConfig); - - _productServiceMock = new Mock(); - _seNameServiceMock = new Mock(); - _seNameServiceMock - .Setup(s => s.TranslationSeNameProperties(It.IsAny>(), - It.IsAny(), It.IsAny>>())) - .ReturnsAsync(new List()); - _seNameServiceMock - .Setup(s => s.ValidateSeName(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync("se-name"); - - var workContextMock = new Mock(); - workContextMock.Setup(w => w.CurrentVendor).Returns(new Grand.Domain.Vendors.Vendor { Id = CurrentVendorId }); - var contextAccessorMock = new Mock(); - contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); - - var translationServiceMock = new Mock(); - translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _service = new ProductViewModelService( - _productServiceMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - translationServiceMock.Object, - new Mock().Object, - new Mock().Object, - contextAccessorMock.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new CurrencySettings(), - new MeasureSettings(), - new TaxSettings(), - _seNameServiceMock.Object, - new Mock().Object); - } - - [TestMethod] - public async Task InsertProductModel_SetsVendorIdFromCurrentVendor() - { - var model = new ProductModel { Name = "New product" }; - - var product = await _service.InsertProductModel(model); - - Assert.AreEqual(CurrentVendorId, product.VendorId); - _productServiceMock.Verify(p => p.InsertProduct(It.Is(x => x.VendorId == CurrentVendorId)), - Times.Once); - } - - [TestMethod] - public async Task PrepareProducts_FiltersSearchByCurrentVendor() - { - IPagedList paged = new PagedList { new() { Id = "p1", VendorId = CurrentVendorId } }; - _productServiceMock.Setup(p => p.SearchProducts( - false, 0, int.MaxValue, It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny(), CurrentVendorId, It.IsAny(), It.IsAny(), false, false, - It.IsAny(), It.IsAny(), null, null, "", It.IsAny(), false, true, false, "", - null, null, ProductSortingEnum.Position, true, It.IsAny())) - .ReturnsAsync((paged, (IList)null)); - - var products = await _service.PrepareProducts(new ProductListModel()); - - Assert.AreEqual(1, products.Count); - _productServiceMock.Verify(p => p.SearchProducts( - false, 0, int.MaxValue, It.IsAny>(), It.IsAny(), It.IsAny(), - It.IsAny(), CurrentVendorId, It.IsAny(), It.IsAny(), false, false, - It.IsAny(), It.IsAny(), null, null, "", It.IsAny(), false, true, false, "", - null, null, ProductSortingEnum.Position, true, It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task DeleteSelected_SkipsProductsNotOwnedByCurrentVendor() - { - var own = new Product { Id = "own", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "own", "other" }, true)) - .ReturnsAsync(new List { own, other }); - - await _service.DeleteSelected(new[] { "own", "other" }); - - _productServiceMock.Verify(p => p.DeleteProduct(own), Times.Once); - _productServiceMock.Verify(p => p.DeleteProduct(other), Times.Never); - } - - [TestMethod] - public async Task InsertRelatedProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertRelatedProductModel(new ProductModel.AddRelatedProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.RelatedProducts.Any(x => x.ProductId2 == "other")); - _productServiceMock.Verify(p => p.InsertRelatedProduct(It.IsAny(), "source"), Times.Never); - } - - [TestMethod] - public async Task InsertSimilarProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - // Regression test for a fixed authorization bug: this used to check - // HasAccessToProduct(productId1) - the product already being edited, which the vendor is - // guaranteed to own - instead of HasAccessToProduct(product), the candidate being linked in - // via `id`. That made the check a no-op: any vendor could link any other vendor's product as - // "similar". Now it checks the candidate, matching InsertRelatedProductModel/ - // InsertBundleProductModel. - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertSimilarProductModel(new ProductModel.AddSimilarProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.SimilarProducts.Any(x => x.ProductId2 == "other")); - _productServiceMock.Verify(p => p.InsertSimilarProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task InsertSimilarProductModel_LinksCandidateOwnedByCurrentVendor() - { - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var own = new Product { Id = "own", VendorId = CurrentVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("own", false)).ReturnsAsync(own); - - await _service.InsertSimilarProductModel(new ProductModel.AddSimilarProductModel { - ProductId = "source", - SelectedProductIds = ["own"] - }); - - Assert.IsTrue(source.SimilarProducts.Any(x => x.ProductId2 == "own")); - _productServiceMock.Verify(p => p.InsertSimilarProduct(It.IsAny()), Times.Once); - } - - [TestMethod] - public async Task InsertBundleProductModel_SkipsCandidateNotOwnedByCurrentVendor() - { - // Same fixed bug as InsertSimilarProductModel, same fix. - var source = new Product { Id = "source", VendorId = CurrentVendorId }; - var other = new Product { Id = "other", VendorId = OtherVendorId }; - _productServiceMock.Setup(p => p.GetProductById("source", true)).ReturnsAsync(source); - _productServiceMock.Setup(p => p.GetProductById("other", false)).ReturnsAsync(other); - - await _service.InsertBundleProductModel(new ProductModel.AddBundleProductModel { - ProductId = "source", - SelectedProductIds = ["other"] - }); - - Assert.IsFalse(source.BundleProducts.Any(x => x.ProductId == "other")); - _productServiceMock.Verify(p => p.InsertBundleProduct(It.IsAny(), It.IsAny()), - Times.Never); - } -} diff --git a/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs b/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs deleted file mode 100644 index 1b9f707250..0000000000 --- a/src/Web/Grand.Web.Vendor/Extensions/ProductsMappingExtensions.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Grand.Business.Core.Interfaces.Common.Directory; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Mapper; -using Grand.Web.Common.Extensions; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Extensions; - -public static class ProductsMappingExtensions -{ - public static ProductModel ToModel(this Product entity, IDateTimeService dateTimeService) - { - var product = entity.MapTo(); - product.MarkAsNewStartDateTime = entity.MarkAsNewStartDateTimeUtc.ConvertToUserTime(dateTimeService); - product.MarkAsNewEndDateTime = entity.MarkAsNewEndDateTimeUtc.ConvertToUserTime(dateTimeService); - product.AvailableStartDateTime = entity.AvailableStartDateTimeUtc.ConvertToUserTime(dateTimeService); - product.AvailableEndDateTime = entity.AvailableEndDateTimeUtc.ConvertToUserTime(dateTimeService); - product.PreOrderDateTime = entity.PreOrderDateTimeUtc.ConvertToUserTime(dateTimeService); - return product; - } - - public static Product ToEntity(this ProductModel model, IDateTimeService dateTimeService) - { - var product = model.MapTo(); - product.MarkAsNewStartDateTimeUtc = model.MarkAsNewStartDateTime.ConvertToUtcTime(dateTimeService); - product.MarkAsNewEndDateTimeUtc = model.MarkAsNewEndDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableStartDateTimeUtc = model.AvailableStartDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableEndDateTimeUtc = model.AvailableEndDateTime.ConvertToUtcTime(dateTimeService); - product.PreOrderDateTimeUtc = model.PreOrderDateTime.ConvertToUtcTime(dateTimeService); - - return product; - } - - public static Product ToEntity(this ProductModel model, Product destination, IDateTimeService dateTimeService) - { - var product = model.MapTo(destination); - product.MarkAsNewStartDateTimeUtc = model.MarkAsNewStartDateTime.ConvertToUtcTime(dateTimeService); - product.MarkAsNewEndDateTimeUtc = model.MarkAsNewEndDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableStartDateTimeUtc = model.AvailableStartDateTime.ConvertToUtcTime(dateTimeService); - product.AvailableEndDateTimeUtc = model.AvailableEndDateTime.ConvertToUtcTime(dateTimeService); - product.PreOrderDateTimeUtc = model.PreOrderDateTime.ConvertToUtcTime(dateTimeService); - return product; - } - - public static ProductModel.ProductAttributeMappingModel ToModel(this ProductAttributeMapping entity) - { - return entity.MapTo(); - } - - public static ProductAttributeMapping ToEntity(this ProductModel.ProductAttributeMappingModel model) - { - return model.MapTo(); - } - - public static ProductAttributeMapping ToEntity(this ProductModel.ProductAttributeMappingModel model, - ProductAttributeMapping destination) - { - return model.MapTo(destination); - } - - public static ProductAttributeCombinationModel ToModel(this ProductAttributeCombination entity) - { - return entity.MapTo(); - } - - public static ProductModel.AddProductSpecificationAttributeModel ToModel(this ProductSpecificationAttribute entity) - { - return entity.MapTo(); - } - - public static ProductSpecificationAttribute ToEntity(this ProductModel.AddProductSpecificationAttributeModel model) - { - return model.MapTo(); - } - - public static ProductSpecificationAttribute ToEntity(this ProductModel.AddProductSpecificationAttributeModel model, - ProductSpecificationAttribute destination) - { - if (model.AttributeTypeId != SpecificationAttributeType.Option) - { - model.SpecificationAttributeId = ""; - model.SpecificationAttributeOptionId = ""; - model.AllowFiltering = false; - } - - return model.MapTo(destination); - } - - public static ProductAttributeValue ToEntity(this PredefinedProductAttributeValue model) - { - return model.MapTo(); - } - - public static ProductModel.TierPriceModel ToModel(this TierPrice entity, IDateTimeService dateTimeService) - { - var tierprice = entity.MapTo(); - tierprice.StartDateTime = entity.StartDateTimeUtc.ConvertToUserTime(dateTimeService); - tierprice.EndDateTime = entity.EndDateTimeUtc.ConvertToUserTime(dateTimeService); - return tierprice; - } - - public static TierPrice ToEntity(this ProductModel.TierPriceModel model, IDateTimeService dateTimeService) - { - var tierprice = model.MapTo(); - tierprice.StartDateTimeUtc = model.StartDateTime.ConvertToUtcTime(dateTimeService); - tierprice.EndDateTimeUtc = model.EndDateTime.ConvertToUtcTime(dateTimeService); - return tierprice; - } - - public static TierPrice ToEntity(this ProductModel.TierPriceModel model, TierPrice destination, - IDateTimeService dateTimeService) - { - var tierprice = model.MapTo(destination); - tierprice.StartDateTimeUtc = model.StartDateTime.ConvertToUtcTime(dateTimeService); - tierprice.EndDateTimeUtc = model.EndDateTime.ConvertToUtcTime(dateTimeService); - return tierprice; - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs b/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs deleted file mode 100644 index f83ddd89a2..0000000000 --- a/src/Web/Grand.Web.Vendor/Mapper/ProductProfile.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Grand.Mapping; -using Grand.Business.Core.Extensions; -using Grand.Domain.Catalog; -using Grand.Infrastructure.Mapper; -using Grand.Web.Common.Extensions; -using Grand.Web.Vendor.Models.Catalog; - -namespace Grand.Web.Vendor.Mapper; - -public class ProductProfile : Profile, IAutoMapperProfile -{ - public ProductProfile() - { - CreateMap() - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.ProductTypeName, mo => mo.Ignore()) - .ForMember(dest => dest.AssociatedToProductId, mo => mo.Ignore()) - .ForMember(dest => dest.AssociatedToProductName, mo => mo.Ignore()) - .ForMember(dest => dest.StockQuantityStr, mo => mo.Ignore()) - .ForMember(dest => dest.CreatedOn, mo => mo.Ignore()) - .ForMember(dest => dest.UpdatedOn, mo => mo.Ignore()) - .ForMember(dest => dest.PictureThumbnailUrl, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableProductLayouts, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableProductAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.AddPictureModel, mo => mo.Ignore()) - .ForMember(dest => dest.ProductPictureModels, mo => mo.Ignore()) - .ForMember(dest => dest.CopyProductModel, mo => mo.Ignore()) - .ForMember(dest => dest.ProductWarehouseInventoryModels, mo => mo.Ignore()) - .ForMember(dest => dest.SeName, mo => mo.MapFrom(src => src.GetSeName("", true))) - .ForMember(dest => dest.AvailableTaxCategories, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableUnits, mo => mo.Ignore()) - .ForMember(dest => dest.PrimaryStoreCurrencyCode, mo => mo.Ignore()) - .ForMember(dest => dest.BaseDimensionIn, mo => mo.Ignore()) - .ForMember(dest => dest.BaseWeightIn, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableDeliveryDates, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableWarehouses, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableBasepriceUnits, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableBasepriceBaseUnits, mo => mo.Ignore()) - .ForPath(dest => dest.CalendarModel.IncBothDate, mo => mo.MapFrom(x => x.IncBothDate)); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()) - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.VendorId, mo => mo.Ignore()) - .ForMember(dest => dest.UpdatedOnUtc, mo => mo.Ignore()) - .ForMember(dest => dest.Coordinates, mo => mo.Ignore()) - .ForMember(dest => dest.ParentGroupedProductId, mo => mo.Ignore()) - .ForMember(dest => dest.ApprovedRatingSum, mo => mo.Ignore()) - .ForMember(dest => dest.NotApprovedRatingSum, mo => mo.Ignore()) - .ForMember(dest => dest.ApprovedTotalReviews, mo => mo.Ignore()) - .ForMember(dest => dest.NotApprovedTotalReviews, mo => mo.Ignore()) - .ForMember(dest => dest.ProductCategories, mo => mo.Ignore()) - .ForMember(dest => dest.ProductCollections, mo => mo.Ignore()) - .ForMember(dest => dest.ProductPictures, mo => mo.Ignore()) - .ForMember(dest => dest.ProductSpecificationAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.ProductWarehouseInventory, mo => mo.Ignore()) - .ForMember(dest => dest.Interval, mo => mo.Ignore()) - .ForMember(dest => dest.ProductAttributeMappings, mo => mo.Ignore()) - .ForMember(dest => dest.ProductAttributeCombinations, mo => mo.Ignore()) - .ForMember(dest => dest.TierPrices, mo => mo.Ignore()) - .ForMember(dest => dest.AppliedDiscounts, mo => mo.Ignore()) - .ForPath(dest => dest.IncBothDate, mo => mo.MapFrom(x => x.CalendarModel.IncBothDate)); - - CreateMap(); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.UseMultipleWarehouses, mo => mo.Ignore()) - .ForMember(dest => dest.PrimaryStoreCurrencyCode, mo => mo.Ignore()) - .ForMember(dest => dest.WarehouseInventoryModels, mo => mo.Ignore()); - CreateMap() - .ForMember(dest => dest.WarehouseInventory, mo => mo.Ignore()) - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.PriceAdjustmentStr, mo => mo.MapFrom(x => x.PriceAdjustment.ToString("N2"))) - .ForMember(dest => dest.WeightAdjustmentStr, mo => mo.MapFrom(x => x.WeightAdjustment.ToString("N2"))); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()) - .ForMember(dest => dest.Locales, mo => mo.MapFrom(x => x.Locales.ToTranslationProperty())); - - CreateMap() - .ForMember(dest => dest.AvailableAttributes, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableOptions, mo => mo.Ignore()); - CreateMap(); - - CreateMap() - .ForMember(dest => dest.AvailableCurrencies, mo => mo.Ignore()); - - CreateMap() - .ForMember(dest => dest.Id, mo => mo.Ignore()); - } - - public int Order => 0; -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs deleted file mode 100644 index 5dc967f131..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditListModel.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class BulkEditListModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchProductName")] - public string SearchProductName { get; set; } - - [UIHint("Category")] - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchCategory")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [UIHint("Collection")] - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.List.SearchCollection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - public IList AvailableProductTypes { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs deleted file mode 100644 index 19d5a5d22b..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/BulkEditProductModel.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class BulkEditProductModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.SKU")] - - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.OldPrice")] - public double OldPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.ManageInventoryMethod")] - public int ManageInventoryMethodId { get; set; } - - public string ManageInventoryMethod { get; set; } - - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.BulkEdit.Fields.Published")] - public bool Published { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs deleted file mode 100644 index 16ae6ab0a1..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/CopyProductModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class CopyProductModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.Name")] - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.CopyImages")] - public bool CopyImages { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Copy.Published")] - public bool Published { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs deleted file mode 100644 index 73b8c098af..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/IProductValidVendor.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Grand.Web.Vendor.Models.Catalog; - -/// -/// Implement on any Vendor-area POST model that carries a product id. The global -/// resolves an -/// IValidator<IProductValidVendor> for every such model and rejects the request unless -/// belongs to the current vendor - see -/// Grand.Web.Vendor.Validators.Catalog.ProductValidVendor. -/// IMPORTANT: if the model carries more than one product-id-shaped field (e.g. an owning/parent id -/// plus a referenced/component id), make sure is bound to whichever id the -/// action actually mutates. A mismatch silently authorizes the wrong product - see -/// ProductModel.BundleProductModel (ProductId vs ProductBundleId) for a bug of this exact shape that -/// was found and fixed. -/// -public interface IProductValidVendor -{ - public string ProductId { get; set; } -} - -/// -/// Implement on Vendor-area POST models that relate two products (e.g. related/similar products). -/// The paired validator (ProductRelatedValidVendor) requires ownership of -/// only, because the consuming actions only ever read/mutate ProductId1's mapping list. Do not widen -/// this to accept ownership of either id ("OR") unless the action is also changed to only ever -/// mutate whichever product is actually owned - an OR check let an attacker satisfy validation via -/// ProductId2 while mutating a ProductId1 they don't own. -/// -public interface IProductRelatedValidVendor -{ - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs deleted file mode 100644 index 876b919d58..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeCombinationModel.cs +++ /dev/null @@ -1,111 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Binders; -using Grand.Web.Common.Models; -using Microsoft.AspNetCore.Mvc; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeCombinationModel : BaseModel -{ - public string Id { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.AllowOutOfStockOrders")] - public bool AllowOutOfStockOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Text")] - public string Text { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Gtin")] - public string Gtin { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.OverriddenPrice")] - [UIHint("DoubleNullable")] - public double? OverriddenPrice { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Picture")] - public string PictureId { get; set; } - - public string PictureThumbnailUrl { get; set; } - - public IList ProductPictureModels { get; set; } = - new List(); - - public IList ProductAttributes { get; set; } = new List(); - - [ModelBinder(BinderType = typeof(CustomAttributesBinder))] - public IList SelectedAttributes { get; set; } - - public IList Warnings { get; set; } = new List(); - - public string ProductId { get; set; } - public string Attributes { get; set; } - - public bool UseMultipleWarehouses { get; set; } - - public IList WarehouseInventoryModels { get; set; } = new List(); - - #region Nested classes - - public class ProductAttributeModel : BaseEntityModel - { - public string ProductAttributeId { get; set; } - public string Name { get; set; } - public string TextPrompt { get; set; } - public bool IsRequired { get; set; } - public AttributeControlType AttributeControlType { get; set; } - public IList Values { get; set; } = new List(); - } - - public class ProductAttributeValueModel : BaseEntityModel - { - public string Name { get; set; } - - public bool IsPreSelected { get; set; } - } - - public class WarehouseInventoryModel : BaseEntityModel - { - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public string WarehouseName { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.WarehouseUsed")] - public bool WarehouseUsed { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombination.WarehouseInventory.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - } - - #endregion -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs deleted file mode 100644 index 6437c3029e..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeConditionModel.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Binders; -using Grand.Web.Common.Models; -using Microsoft.AspNetCore.Mvc; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeConditionModel : BaseModel, IProductValidVendor -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.EnableCondition")] - public bool EnableCondition { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.Attributes")] - public string SelectedProductAttributeId { get; set; } - - public IList ProductAttributes { get; set; } = new List(); - - [ModelBinder(BinderType = typeof(CustomAttributesBinder))] - public IList SelectedAttributes { get; set; } - - public string ProductAttributeMappingId { get; set; } - public string ProductId { get; set; } - - #region Nested classes - - public class ProductAttributeModel : BaseEntityModel - { - public string ProductAttributeId { get; set; } - - public string Name { get; set; } - - public string TextPrompt { get; set; } - - public bool IsRequired { get; set; } - - public AttributeControlType AttributeControlType { get; set; } - - public IList Values { get; set; } = new List(); - } - - public class ProductAttributeValueModel : BaseEntityModel - { - public string Name { get; set; } - - public bool IsPreSelected { get; set; } - } - - #endregion -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs deleted file mode 100644 index 19ad1555b5..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductAttributeModel.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Infrastructure.Validators; -using Grand.Web.Common.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductAttributeLocalizedModel : ILocalizedModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.Fields.Description")] - [SanitizeHtml] - public string Description { get; set; } - - public string LanguageId { get; set; } -} - -public class PredefinedProductAttributeValueModel : BaseEntityModel, - ILocalizedModel -{ - public string ProductAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.PriceAdjustment")] - public double PriceAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.PriceAdjustment")] - //used only on the values list page - public string PriceAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.WeightAdjustment")] - public double WeightAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.WeightAdjustment")] - //used only on the values list page - public string WeightAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Cost")] - public double Cost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.IsPreSelected")] - public bool IsPreSelected { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public IList Locales { get; set; } = - new List(); -} - -public class PredefinedProductAttributeValueLocalizedModel : ILocalizedModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Attributes.ProductAttributes.PredefinedValues.Fields.Name")] - - public string Name { get; set; } - - public string LanguageId { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs deleted file mode 100644 index 68bece177f..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductListModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductListModel : BaseModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductName")] - public string SearchProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCategory")] - [UIHint("Category")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchIncludeSubCategories")] - public bool SearchIncludeSubCategories { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCollection")] - [UIHint("Collection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchWarehouse")] - public string SearchWarehouseId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchPublished")] - public int SearchPublishedId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.GoDirectlyToSku")] - - public string GoDirectlyToSku { get; set; } - - public IList AvailableWarehouses { get; set; } = new List(); - public IList AvailableProductTypes { get; set; } = new List(); - public IList AvailablePublishedOptions { get; set; } = new List(); -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs deleted file mode 100644 index 4f87d65eee..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductModel.cs +++ /dev/null @@ -1,992 +0,0 @@ -using Grand.Domain.Catalog; -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; -using Grand.Web.Common.Models; -using Grand.Infrastructure.Validators; -using Microsoft.AspNetCore.Mvc.Rendering; -using System.ComponentModel.DataAnnotations; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductModel : BaseEntityModel, ILocalizedModel -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ID")] - public override string Id { get; set; } - - //picture thumbnail - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.PictureThumbnailUrl")] - public string PictureThumbnailUrl { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductType")] - public int ProductTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductType")] - public string ProductTypeName { get; set; } - - public bool AuctionEnded { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AssociatedToProductName")] - public string AssociatedToProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AssociatedToProductName")] - public string AssociatedToProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.VisibleIndividually")] - public bool VisibleIndividually { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductLayout")] - public string ProductLayoutId { get; set; } - - public IList AvailableProductLayouts { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Name")] - public string Name { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShortDescription")] - public string ShortDescription { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.FullDescription")] - public string FullDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Flag")] - public string Flag { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AdminComment")] - public string AdminComment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Brand")] - [UIHint("Brand")] - public string BrandId { get; set; } - - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaKeywords")] - public string MetaKeywords { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaDescription")] - public string MetaDescription { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaTitle")] - public string MetaTitle { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.SeName")] - public string SeName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowCustomerReviews")] - public bool AllowCustomerReviews { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.GTIN")] - public virtual string Gtin { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsGiftVoucher")] - public bool IsGiftVoucher { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.GiftVoucherType")] - public int GiftVoucherTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OverriddenGiftVoucherAmount")] - [UIHint("DoubleNullable")] - public double? OverGiftAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RequireOtherProducts")] - public bool RequireOtherProducts { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RequiredProductIds")] - public string RequiredProductIds { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AutomaticallyAddRequiredProducts")] - public bool AutoAddRequiredProducts { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsRecurring")] - public bool IsRecurring { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringCycleLength")] - public int RecurringCycleLength { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringCyclePeriod")] - public int RecurringCyclePeriodId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.RecurringTotalCycles")] - public int RecurringTotalCycles { get; set; } - - //calendar - public GenerateCalendarModel CalendarModel { get; set; } = new(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsShipEnabled")] - public bool IsShipEnabled { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsFreeShipping")] - public bool IsFreeShipping { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShipSeparately")] - public bool ShipSeparately { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AdditionalShippingCharge")] - public double AdditionalShippingCharge { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DeliveryDate")] - public string DeliveryDateId { get; set; } - - public IList AvailableDeliveryDates { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsTaxExempt")] - public bool IsTaxExempt { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.TaxCategory")] - public string TaxCategoryId { get; set; } - - public IList AvailableTaxCategories { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.IsTelecommunicationsOrBroadcastingOrElectronicServices")] - public bool IsTele { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ManageInventoryMethod")] - public int ManageInventoryMethodId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.UseMultipleWarehouses")] - public bool UseMultipleWarehouses { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public IList AvailableWarehouses { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StockQuantity")] - public string StockQuantityStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayStockAvailability")] - public bool StockAvailability { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayStockQuantity")] - public bool DisplayStockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MinStockQuantity")] - public int MinStockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.LowStockActivity")] - public int LowStockActivityId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BackorderMode")] - public int BackorderModeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowOutOfStockSubscriptions")] - public bool AllowOutOfStockSubscriptions { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OrderMinimumQuantity")] - public int OrderMinimumQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OrderMaximumQuantity")] - public int OrderMaximumQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AllowedQuantities")] - public string AllowedQuantities { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.NotReturnable")] - public bool NotReturnable { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisableBuyButton")] - public bool DisableBuyButton { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisableWishlistButton")] - public bool DisableWishlistButton { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableForPreOrder")] - public bool AvailableForPreOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.PreOrderDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? PreOrderDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CallForPrice")] - public bool CallForPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.OldPrice")] - public double OldPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CatalogPrice")] - public double CatalogPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.StartPrice")] - public double StartPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ProductCost")] - public double ProductCost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.EnteredPrice")] - public bool EnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MinEnteredPrice")] - public double MinEnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MaxEnteredPrice")] - public double MaxEnteredPrice { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceEnabled")] - public bool BasepriceEnabled { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceAmount")] - public double BasepriceAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceUnit")] - public string BasepriceUnitId { get; set; } - - public IList AvailableBasepriceUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceBaseAmount")] - public double BasepriceBaseAmount { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.BasepriceBaseUnit")] - public string BasepriceBaseUnitId { get; set; } - - public IList AvailableBasepriceBaseUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNew")] - public bool MarkAsNew { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNewStartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? MarkAsNewStartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MarkAsNewEndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? MarkAsNewEndDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Unit")] - public string UnitId { get; set; } - - public IList AvailableUnits { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Weight")] - public double Weight { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Length")] - public double Length { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Width")] - public double Width { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Height")] - public double Height { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableStartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? AvailableStartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.AvailableEndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? AvailableEndDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderCategory")] - public int DisplayOrderCategory { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderBrand")] - public int DisplayOrderBrand { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderCollection")] - public int DisplayOrderCollection { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.DisplayOrderOnSale")] - public int OnSale { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Published")] - public bool Published { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.CreatedOn")] - public DateTime? CreatedOn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.UpdatedOn")] - public DateTime? UpdatedOn { get; set; } - - public long Ticks { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - public string BaseDimensionIn { get; set; } - public string BaseWeightIn { get; set; } - - //product attributes - public IList AvailableProductAttributes { get; set; } = new List(); - - //pictures - public ProductPictureModel AddPictureModel { get; set; } = new(); - public IList ProductPictureModels { get; set; } = new List(); - - //multiple warehouses - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory")] - public IList ProductWarehouseInventoryModels { get; set; } = - new List(); - - //copy product - public CopyProductModel CopyProductModel { get; set; } = new(); - - public IList Locales { get; set; } = new List(); - - #region Nested classes - - public class AddProductModel : BaseModel - { - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductName")] - - public string SearchProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCategory")] - [UIHint("Category")] - public string SearchCategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.Brand")] - [UIHint("Brand")] - public string SearchBrandId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchCollection")] - [UIHint("Collection")] - public string SearchCollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.List.SearchProductType")] - public int SearchProductTypeId { get; set; } - - public IList AvailableProductTypes { get; set; } = new List(); - } - - - public class AddRequiredProductModel : AddProductModel; - - public class AddProductSpecificationAttributeModel : BaseModel, IProductValidVendor - { - public string Id { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.SpecificationAttribute")] - public string SpecificationAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.AttributeType")] - public SpecificationAttributeType AttributeTypeId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.SpecificationAttributes.Fields.SpecificationAttributeOption")] - public string SpecificationAttributeOptionId { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.CustomName")] - public string CustomName { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.CustomValue")] - public string CustomValue { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering")] - public bool AllowFiltering { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage")] - public bool ShowOnProductPage { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SpecificationAttributes.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public IList AvailableAttributes { get; set; } = new List(); - public IList AvailableOptions { get; set; } = new List(); - - public string ProductId { get; set; } - } - - public class ProductPictureModel : BaseEntityModel, - ILocalizedModel, IProductValidVendor - { - [UIHint("MultiPicture")] - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Picture")] - public string PictureId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Picture")] - public string PictureUrl { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Admin.Catalog.Products.Pictures.Fields.IsDefault")] - public bool IsDefault { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideAltAttribute")] - public string AltAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideTitleAttribute")] - public string TitleAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.Style")] - public string Style { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.ExtraField")] - public string ExtraField { get; set; } - - public IList Locales { get; set; } = new List(); - public string ProductId { get; set; } - - public class ProductPictureLocalizedModel : ILocalizedModelLocal - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideAltAttribute")] - public string AltAttribute { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Pictures.Fields.OverrideTitleAttribute")] - public string TitleAttribute { get; set; } - - public string LanguageId { get; set; } - } - } - - public class ProductCategoryModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Categories.Fields.Category")] - public string Category { get; set; } - - public string CategoryId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Categories.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class ProductCollectionModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Collections.Fields.Collection")] - public string Collection { get; set; } - - public string CollectionId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Collections.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class RelatedProductModel : BaseEntityModel, IProductRelatedValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.RelatedProducts.Fields.Product")] - public string Product2Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.RelatedProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } - } - - public class AddRelatedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class SimilarProductModel : BaseEntityModel, IProductRelatedValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.SimilarProducts.Fields.Product")] - public string Product2Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.SimilarProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId1 { get; set; } - public string ProductId2 { get; set; } - } - - public class AddSimilarProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class BundleProductModel : BaseEntityModel, IProductValidVendor - { - public string ProductBundleId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.Product")] - public string ProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.BundleProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class AddBundleProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class AssociatedProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.AssociatedProducts.Fields.Product")] - public string ProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.AssociatedProducts.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - public string ProductId { get; set; } - } - - public class AddAssociatedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class CrossSellProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.CrossSells.Fields.Product")] - public string Product2Name { get; set; } - - public string ProductId { get; set; } - } - - public class AddCrossSellProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class RecommendedProductModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.Recommended.Fields.Product")] - public string Product2Name { get; set; } - - public string ProductId { get; set; } - } - - public class AddRecommendedProductModel : AddProductModel, IProductValidVendor - { - public string[] SelectedProductIds { get; set; } - public string ProductId { get; set; } - } - - public class ProductPriceModel : BaseEntityModel - { - public string CurrencyCode { get; set; } - - public double Price { get; set; } - } - - public class TierPriceModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.CurrencyCode")] - public string CurrencyCode { get; set; } - - public IList AvailableCurrencies { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.Price")] - public double Price { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.StartDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? StartDateTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.TierPrices.Fields.EndDateTime")] - [UIHint("DateTimeNullable")] - public DateTime? EndDateTime { get; set; } - - public string ProductId { get; set; } - } - - public class TierPriceDeleteModel : BaseEntityModel, IProductValidVendor - { - public string ProductId { get; set; } - } - - public class ProductWarehouseInventoryModel : BaseModel - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse")] - public string WarehouseId { get; set; } - - public string WarehouseCode { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse")] - public string WarehouseName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed")] - public bool WarehouseUsed { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity")] - public int ReservedQuantity { get; set; } - } - - public class ReservationModel : BaseEntityModel, IProductValidVendor - { - public string ReservationId { get; set; } - public DateTime Date { get; set; } - public string Resource { get; set; } - public string Parameter { get; set; } - public string OrderId { get; set; } - public string Duration { get; set; } - public string ProductId { get; set; } - } - - public class BidModel : BaseEntityModel, IProductValidVendor - { - public string BidId { get; set; } - public DateTime Date { get; set; } - public string CustomerId { get; set; } - public string Email { get; set; } - public string Amount { get; set; } - public string OrderId { get; set; } - public string ProductId { get; set; } - } - - public class GenerateCalendarModel : BaseModel, IProductValidVendor - { - public GenerateCalendarModel() - { - Interval = 1; - Quantity = 1; - } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.StartDate")] - [UIHint("DateNullable")] - public DateTime? StartDate { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.StartTime")] - [UIHint("Time")] - public DateTime StartTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.EndDate")] - [UIHint("DateNullable")] - public DateTime? EndDate { get; set; } - - [UIHint("Time")] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.EndTime")] - public DateTime EndTime { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Interval")] - public int Interval { get; set; } = 1; - - public int IntervalUnit { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.IncBothDate")] - public bool IncBothDate { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Resource")] - public string Resource { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Parameter")] - public string Parameter { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Monday")] - public bool Monday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Tuesday")] - public bool Tuesday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Wednesday")] - public bool Wednesday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Thursday")] - public bool Thursday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Friday")] - public bool Friday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Saturday")] - public bool Saturday { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Calendar.Sunday")] - public bool Sunday { get; set; } - - public string ProductId { get; set; } - } - - public class ProductAttributeMappingModel : BaseEntityModel, IProductValidVendor - { - public string ProductAttributeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.Attribute")] - public string ProductAttribute { get; set; } - - public IList AvailableProductAttribute { get; set; } = new List(); - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.TextPrompt")] - public string TextPrompt { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.IsRequired")] - public bool IsRequired { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.ShowOnCatalogPage")] - public bool ShowOnCatalogPage { get; set; } - - public AttributeControlType AttributeControlTypeId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.AttributeControlType")] - public string AttributeControlType { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Fields.Combination")] - public bool Combination { get; set; } - - public bool ShouldHaveValues { get; set; } - public int TotalValues { get; set; } - - //validation fields - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules")] - public bool ValidationRulesAllowed { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MinLength")] - [UIHint("Int32Nullable")] - public int? ValidationMinLength { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.MaxLength")] - [UIHint("Int32Nullable")] - public int? ValidationMaxLength { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileAllowedExtensions")] - - public string ValidationFileAllowedExtensions { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.FileMaximumSize")] - [UIHint("Int32Nullable")] - public int? ValidationFileMaximumSize { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules.DefaultValue")] - - public string DefaultValue { get; set; } - - public string ValidationRulesString { get; set; } - - //condition - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Condition")] - public bool ConditionAllowed { get; set; } - - public string ConditionString { get; set; } - public string ProductId { get; set; } - } - - public class ProductAttributeValueListModel : BaseModel, IProductValidVendor - { - public string ProductName { get; set; } - - public string ProductAttributeMappingId { get; set; } - - public string ProductAttributeName { get; set; } - public string ProductId { get; set; } - } - - public class ProductAttributeValueModel : BaseEntityModel, ILocalizedModel, - IProductValidVendor - { - public string ProductAttributeMappingId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AttributeValueType")] - public AttributeValueType AttributeValueTypeId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AttributeValueType")] - public string AttributeValueTypeName { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct")] - public string AssociatedProductId { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct")] - public string AssociatedProductName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name")] - - public string Name { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.ColorSquaresRgb")] - - public string ColorSquaresRgb { get; set; } - - public bool DisplayColorSquaresRgb { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.ImageSquaresPicture")] - [UIHint("Picture")] - public string ImageSquaresPictureId { get; set; } - - public bool DisplayImageSquaresPicture { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.PriceAdjustment")] - public double PriceAdjustment { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.PriceAdjustment")] - //used only on the values list page - public string PriceAdjustmentStr { get; set; } - - public string PrimaryStoreCurrencyCode { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.WeightAdjustment")] - public double WeightAdjustment { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.WeightAdjustment")] - //used only on the values list page - public string WeightAdjustmentStr { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Cost")] - public double Cost { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Quantity")] - public int Quantity { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.IsPreSelected")] - public bool IsPreSelected { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.DisplayOrder")] - public int DisplayOrder { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Picture")] - public string PictureId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Picture")] - public string PictureThumbnailUrl { get; set; } - - public IList ProductPictureModels { get; set; } = new List(); - - public IList Locales { get; set; } = - new List(); - - public string ProductId { get; set; } - - #region Nested classes - - public class AssociateProductToAttributeValueModel : AddProductModel - { - public string AssociatedToProductId { get; set; } - } - - #endregion - } - - public class ProductAttributeValueLocalizedModel : ILocalizedModelLocal - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name")] - - public string Name { get; set; } - - public string LanguageId { get; set; } - } - - public class ProductAttributeCombinationModel : BaseEntityModel, IProductValidVendor - { - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Attributes")] - public string Attributes { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.StockQuantity")] - public int StockQuantity { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.AllowOutOfStockOrders")] - public bool AllowOutOfStockOrders { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Sku")] - public string Sku { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Mpn")] - public string Mpn { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.Gtin")] - public string Gtin { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.OverriddenPrice")] - [UIHint("DoubleNullable")] - public double? OverriddenPrice { get; set; } - - [GrandResourceDisplayName( - "Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Fields.NotifyAdminForQuantityBelow")] - public int NotifyAdminForQuantityBelow { get; set; } - - public string ProductId { get; set; } - } - - public class ProductAttributeCombinationTierPricesModel : BaseEntityModel, IProductValidVendor - { - public string ProductAttributeCombinationId { get; set; } - - /// - /// Gets or sets the quantity - /// - public int Quantity { get; set; } - - /// - /// Gets or sets the price - /// - public double Price { get; set; } - - public string ProductId { get; set; } - } - - #endregion -} - -public class ProductLocalizedModel : ILocalizedModelLocal, ISlugModelLocal -{ - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.Name")] - - public string Name { get; set; } - - [SanitizeHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.ShortDescription")] - public string ShortDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.FullDescription")] - [SanitizeHtml] - public string FullDescription { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaKeywords")] - [NoHtml] - public string MetaKeywords { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaDescription")] - public string MetaDescription { get; set; } - - [NoHtml] - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.MetaTitle")] - public string MetaTitle { get; set; } - - public string LanguageId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.Products.Fields.SeName")] - public string SeName { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs deleted file mode 100644 index 05ef05ad29..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductReviewModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Grand.Infrastructure.ModelBinding; -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductReviewModel : BaseEntityModel -{ - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Product")] - public string ProductId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Product")] - public string ProductName { get; set; } - - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Store")] - public string StoreName { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Customer")] - public string CustomerId { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Customer")] - public string CustomerInfo { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Title")] - public string Title { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.ReviewText")] - public string ReviewText { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.ReplyText")] - public string ReplyText { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Signature")] - public string Signature { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.Rating")] - public int Rating { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.IsApproved")] - public bool IsApproved { get; set; } - - [GrandResourceDisplayName("Vendor.Catalog.ProductReviews.Fields.CreatedOn")] - public DateTime CreatedOn { get; set; } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs b/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs deleted file mode 100644 index ea811af699..0000000000 --- a/src/Web/Grand.Web.Vendor/Models/Catalog/ProductSpecificationAttributeModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Grand.Infrastructure.Models; - -namespace Grand.Web.Vendor.Models.Catalog; - -public class ProductSpecificationAttributeModel : BaseEntityModel, IProductValidVendor -{ - public int AttributeTypeId { get; set; } - - public string AttributeTypeName { get; set; } - - public string AttributeName { get; set; } - - public string AttributeId { get; set; } - - public string ValueRaw { get; set; } - - public bool AllowFiltering { get; set; } - - public bool ShowOnProductPage { get; set; } - - public int DisplayOrder { get; set; } - - public string SpecificationAttributeOptionId { get; set; } - - public string ProductId { get; set; } -} \ No newline at end of file From f0f2c0616a740a9547fccfe15a8bf94ab96c1e4c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:07:35 +0200 Subject: [PATCH 058/147] Trim VendorMappingTests to non-Product cases, delete orphaned Vendor Catalog models/mapper (ARCH-001 Phase 1) Grand.Web.Vendor/Models/Catalog/*.cs, Mapper/ProductProfile.cs, and Extensions/ProductsMappingExtensions.cs were orphaned once Task 12 repointed Vendor's _ViewImports.cshtml to AdminShared's models/service - nothing in src/Web, src/Tests, or src/Plugins still binds them (confirmed by a wide grep across all three; the only hits outside the deleted files themselves were doc comments and the still-live, unrelated Vendor.Mapper.AddressProfile/VendorProfile). Deleted rather than left inert, since ProductsMappingExtensions's ToModel/ToEntity would otherwise silently shadow if anything ever called them again. Grand.Mapping.Tests/Vendor/VendorMappingTests.cs bound the now-deleted Grand.Web.Vendor.Models.Catalog namespace for its Product/ProductAttributeMapping/ ProductAttributeCombination cases. Those cases are superseded by Grand.Mapping.Tests.AdminShared.CatalogProductMappingTests, which covers the same Product/ProductAttributeMapping/ProductAttributeCombination <-> ProductModel mappings via AdminShared's ProductProfile - the profile Vendor now actually uses. Trimmed the file down to its still-live Address/Vendor cases; deleted the four orphaned .verified.txt snapshots for the removed test methods. Co-Authored-By: Claude Sonnet 5 --- .../Vendor/VendorMappingTests.cs | 80 ++----------------- 1 file changed, 7 insertions(+), 73 deletions(-) diff --git a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs index da92f106fe..c055c1ddea 100644 --- a/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs +++ b/src/Tests/Grand.Mapping.Tests/Vendor/VendorMappingTests.cs @@ -1,18 +1,22 @@ using Grand.Mapping; -using Grand.Domain.Catalog; using Grand.Domain.Common; using Grand.Web.Vendor.Mapper; -using Grand.Web.Vendor.Models.Catalog; using Grand.Web.Vendor.Models.Common; using Grand.Web.Vendor.Models.Vendor; using Microsoft.VisualStudio.TestTools.UnitTesting; using VerifyMSTest; using AddressProfile = Grand.Web.Vendor.Mapper.AddressProfile; -using ProductProfile = Grand.Web.Vendor.Mapper.ProductProfile; using VendorProfile = Grand.Web.Vendor.Mapper.VendorProfile; namespace Grand.Mapping.Tests.Vendor; +// Product-related cases (Product/ProductAttributeMapping/ProductAttributeCombination <-> Vendor's +// Models.Catalog) were removed here (ARCH-001 Phase 1 Task 13): the Vendor.Mapper.ProductProfile they +// exercised, and the Vendor.Models.Catalog types they mapped to/from, were deleted as orphans once +// Task 12 repointed Vendor's _ViewImports.cshtml to AdminShared's models/service. The equivalent +// coverage - mapping Product/ProductAttributeMapping/ProductAttributeCombination to/from AdminShared's +// ProductModel via AdminShared's ProductProfile, which Vendor now uses - lives in +// Grand.Mapping.Tests.AdminShared.CatalogProductMappingTests. [TestClass] public class VendorMappingTests : VerifyBase { @@ -23,7 +27,6 @@ public void Setup() { var config = new MapperConfiguration(cfg => { cfg.AddProfile(); - cfg.AddProfile(); cfg.AddProfile(); }); _mapper = config.CreateMapper(); @@ -88,73 +91,4 @@ public Task VendorModel_ToVendor() }; return Verify(_mapper.Map(model)); } - - // ── Product ─────────────────────────────────────────────────────────────── - - [TestMethod] - public Task Product_ToVendorProductModel() - { - var entity = new Product { - Id = "prod-v-1", - Name = "Vendor Product", - ShortDescription = "Short desc", - FullDescription = "Full description", - Sku = "VSKU001", - Price = 49.99, - OldPrice = 59.99, - Published = true, - StockQuantity = 50, - Weight = 1.5, - DisplayOrder = 1, - ProductTypeId = (ProductType)5 - }; - return Verify(_mapper.Map(entity)); - } - - [TestMethod] - public Task VendorProductModel_ToProduct() - { - var model = new ProductModel { - Name = "New Product", - ShortDescription = "Short", - FullDescription = "Full", - Sku = "SKU002", - Price = 39.99, - Published = true, - StockQuantity = 25 - }; - return Verify(_mapper.Map(model)); - } - - // ── ProductAttributeMapping ─────────────────────────────────────────────── - - [TestMethod] - public Task ProductAttributeMapping_ToVendorModel() - { - var entity = new ProductAttributeMapping { - Id = "pam-v-1", - ProductAttributeId = "pa-1", - TextPrompt = "Choose size", - IsRequired = true, - AttributeControlTypeId = (AttributeControlType)1, - DisplayOrder = 0 - }; - return Verify(_mapper.Map(entity)); - } - - // ── ProductAttributeCombination ─────────────────────────────────────────── - - [TestMethod] - public Task ProductAttributeCombination_ToVendorModel() - { - var entity = new ProductAttributeCombination { - Id = "pac-v-1", - Sku = "COMB-SKU", - StockQuantity = 5, - AllowOutOfStockOrders = false, - OverriddenPrice = null, - NotifyAdminForQuantityBelow = 1 - }; - return Verify(_mapper.Map(entity)); - } } From eec78552b623616653830b61615c66148b69acd1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:37:16 +0200 Subject: [PATCH 059/147] Add ARCH-001 Phase 2 (view consolidation) design spec --- ...rch001-phase2-view-consolidation-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md diff --git a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md new file mode 100644 index 0000000000..e53f0eeac5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md @@ -0,0 +1,192 @@ +# ARCH-001 Phase 2 — Product view consolidation design + +Date: 2026-08-17 +Status: Approved, ready for implementation planning + +## Problem + +Phase 1 (`docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md`, +implemented on branch `arch001/phase1-product-consolidation`, PR #790) consolidated +`ProductController` and `ProductViewModelService` into `Grand.Web.AdminShared`, +reducing each host's controller to a ~20-70 line subclass of `BaseProductController`. +The one duplication Phase 1 explicitly deferred is views: `Grand.Web.Admin`, +`Grand.Web.Store`, and `Grand.Web.Vendor` each still carry their own copy of +`Product/*.cshtml` (51 / 51 / 49 files, ~9300 / ~9060 / ~8600 lines). Spot-check +diff of `List.cshtml` (Admin vs Store) confirms the same pattern Phase 1 found in +controllers: some files differ only in a hardcoded area string or resource-key +prefix, others have a real functional difference (Admin's bulk export/import/ +delete panel and `SearchStoreId`/`SearchVendorId` filters are absent from Store's +`List.cshtml`; Vendor lacks `CreateOrUpdate.Discounts.cshtml` and +`CreateOrUpdate.Documents.cshtml` entirely). + +This spec covers **only the `Product` view set**, the second and final slice of +the Phase 1/2 split already anticipated in the Phase 1 spec's "Phase 2 — View +consolidation" section. It supersedes that section with a concrete, checked +design. + +## Existing precedent + +Plugins in this repo already compile Razor views into their own assembly and +have them discovered at runtime — e.g. `src/Plugins/DiscountRules.Standard/ +DiscountRules.Standard.csproj` uses `Sdk="Microsoft.NET.Sdk.Razor"` with +`true`. Per project memory +`reference_running_the_storefront`, "Plugin views compile into the plugin DLL." +This is the same mechanism ASP.NET Core uses for Razor Class Libraries consumed +via `ProjectReference` (MSBuild auto-generates a `RelatedAssembly` attribute on +the consuming project, and `ApplicationPartManager` auto-discovers the +referenced assembly's compiled views) — no plugin-loading machinery is needed +for this case since `Grand.Web.AdminShared` is already a compile-time +`ProjectReference` from all three hosts. + +`Grand.Web.Common/View/ViewLocationExpander.cs` already implements one +conditional, additive branch (`ThemeKey`, for storefront theme overrides). This +design adds a second, independent branch to the same class rather than +introducing a new expander. + +## Goals + +- Delete the ~150-file, ~27000-line-total duplication the same way Phase 1 + deleted the controller/service duplication: one canonical copy per view, + living in `Grand.Web.AdminShared`, with host-specific overrides only where a + real functional difference exists. +- No change to deployability: each host stays independently buildable and + deployable; views arrive via the existing `ProjectReference`, not a new + packaging or runtime-discovery mechanism. +- Host-specific views continue to render as they do today — this is a pure + dedup, not a UX change (aside from the deliberate, already-known Store/Vendor + feature gaps captured in Phase 1's controller work). + +## Non-goals + +- No new automated test infrastructure. This repo has no `WebApplicationFactory` + usage anywhere (confirmed by search) and host startup is gated on + `DataSettingsManager.DatabaseIsInstalled()`, meaning a real integration-test + harness would need Mongo (e.g. Testcontainers) — a project of its own. Out of + scope here; verification stays the manual/characterization pass the Phase 1 + spec already anticipated. +- No splitting of host-specific-difference views into shared skeleton + partial + override. A view with a real functional difference stays a whole-file, + host-specific override. Revisit only if a future file turns out to be >80% + identical with one small differing block — decide per-file during migration, + default to whole-file override (YAGNI). +- No change to non-Product views. Order/Category/Collection view consolidation + is future work enabled, not started, by this design (same boundary Phase 1 + drew for controllers/services). +- No generalized `IAdminAreaContext` or view-model changes — this is a view + file relocation plus one expander branch, nothing in `BaseProductController` + or `ProductViewModelService` changes. + +## Design + +### 1. `Grand.Web.AdminShared` becomes a Razor Class Library + +Change `src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj`: + +```xml + + + + enable + true + + + +``` + +No other project in the solution needs to change how it references AdminShared +— the three hosts already have a `ProjectReference` to it from Phase 1. + +### 2. View location: `Grand.Web.AdminShared/Views/Product/*.cshtml` + +Not under an `Areas/` folder — AdminShared has no area of its own. The relative +path a Razor view compiles under (`/Views/Product/List.cshtml`) becomes its +lookup key application-wide, independent of which assembly compiled it. + +### 3. `ViewLocationExpander` gets a second, independent branch + +`src/Web/Grand.Web.Common/View/ViewLocationExpander.cs`, in +`ExpandViewLocations`: + +```csharp +public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) +{ + if (context.Values.TryGetValue(ThemeKey, out _)) + { + var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); + viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + } + + if (IsAdminSharedController(context.ActionContext.ActionDescriptor)) + viewLocations = viewLocations.Append("/Views/{1}/{0}.cshtml"); + + return viewLocations; +} + +private static bool IsAdminSharedController(ActionDescriptor descriptor) +{ + if (descriptor is not ControllerActionDescriptor cad) return false; + for (var t = cad.ControllerTypeInfo.AsType(); t is not null; t = t.BaseType) + if (t.Namespace == "Grand.Web.AdminShared.Controllers") + return true; + return false; +} +``` + +Generic namespace check, not a hardcoded `BaseProductController` reference — +Phase 3 (Order, Category, ...) gets the fallback automatically the moment a +`Base*Controller` lands in that namespace, with zero further change to this +file. The `Append` (not prepend) is what makes host-specific overrides win: +`RazorViewEngine` tries each location in order and returns the first file that +exists, so a host's own `Areas/{Area}/Views/Product/X.cshtml` — which appears +earlier in the default location list — always wins over the AdminShared +fallback when both exist. + +The two branches (`ThemeKey` / AdminShared) are independent and additive — +Grand.Web (storefront) has no `Grand.Web.AdminShared.Controllers`-derived +controllers, and Admin/Store/Vendor have no theme context, so in practice at +most one branch ever fires per request. + +### 4. Per-file migration classification + +For each of the ~53 distinct Product view filenames (union of the three +hosts), read all present variants and classify: + +| Case | Resolution | +|---|---| +| Byte-identical, or differs only in a hardcoded area string / resource-key prefix already unified behind `IAdminDataScope` in Phase 1 | One file in `AdminShared/Views/Product/`, using `ViewContext.RouteData.Values["area"]` (or the equivalent existing helper) instead of a literal `Constants.AreaAdmin`/`AreaStore`/`AreaVendor`; delete the 2-3 host copies. | +| Differs only by a capability flag Phase 1 already introduced (e.g. `Model.ShowStoreSelector`, `scope.ResourceKeyPrefix`) | One file with the existing conditional (`@if (Model.ShowStoreSelector) { ... }`); delete host copies. | +| Real functional difference (Admin-only bulk export/import/delete panel and store/vendor search filters on `List.cshtml`; Vendor missing `Discounts`/`Documents` partials entirely) | Stays as a whole-file, host-specific override in that host's own `Areas/{Area}/Views/Product/` folder. Not moved to AdminShared. | + +This mirrors Phase 1's Task 8/10 discipline: one file (or a tightly-coupled +small group, e.g. `CreateOrUpdate.*.cshtml` region partials with a shared +parent) per checklist row, each read across all present hosts, classified, +migrated, and committed independently — subagent-driven-development, one +subagent per row. + +### 5. Verification + +- `dotnet build GrandNode.sln` after the RCL conversion and after each + migration batch — a missing view at runtime is a startup-time or + render-time failure, not a compile error, so build success alone is not + sufficient evidence. +- Manual/characterization pass per host (per the original Phase 1 spec's + Testing section): List → Create → Edit → Save for an existing product, + once per host (Admin/Store/Vendor), confirming the page renders with the + expected host-specific content (or lack thereof) and no + `InvalidOperationException: The view '...' was not found` error. +- Existing MSTest suites (`Grand.Web.Admin.Tests`, `Grand.Web.Store.Tests`, + `Grand.Web.Vendor.Tests`) stay green throughout — they don't render Razor + views today (confirmed: no `WebApplicationFactory` usage in the repo), so + they are a regression guard for the controller/service layer this touches + incidentally (e.g. if a `.cshtml` move breaks a `[ViewComponent]` or model + binding), not a substitute for the manual pass above. + +## Out of scope + +- Automated view-rendering tests (`WebApplicationFactory`, Testcontainers-backed + Mongo) — noted above as a real gap, but its own project; revisit separately + if the manual pass proves too costly to repeat as Phase 3+ lands. +- Any entity other than Product. +- Merging the three hosts into one deployable app (same standing rejection as + Phase 1). From d0ea382af735fc1b715b59558413d08cbdd8554b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:43:31 +0200 Subject: [PATCH 060/147] Add ARCH-001 Phase 2 (view consolidation) implementation plan --- ...08-17-arch001-phase2-view-consolidation.md | 701 ++++++++++++++++++ 1 file changed, 701 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md diff --git a/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md b/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md new file mode 100644 index 0000000000..258c7941cc --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md @@ -0,0 +1,701 @@ +# ARCH-001 Phase 2 (View Consolidation) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Task 4 is a per-file checklist (one `.cshtml` filename per row) — when using subagent-driven-development, dispatch one subagent per checklist row, not one subagent for the whole task. + +**Goal:** Delete the three duplicated copies of `Product/*.cshtml` (51 Admin / +51 Store / 49 Vendor files) by moving one canonical copy of each into +`Grand.Web.AdminShared`, discovered at runtime via a `ViewLocationExpander` +fallback, with host-specific overrides kept only where a real functional +difference exists. + +**Architecture:** `Grand.Web.AdminShared` becomes a Razor Class Library +(`Sdk="Microsoft.NET.Sdk.Razor"`, `AddRazorSupportForMvc=true`) — the same +mechanism this repo's plugins already use to ship views inside their own DLL. +`Grand.Web.Common/View/ViewLocationExpander.cs` gets a second, independent +branch: when the executing controller derives from a type in +`Grand.Web.AdminShared.Controllers`, it appends `/Views/{1}/{0}.cshtml` to the +end of the candidate view locations, so a host's own override (checked first +by `RazorViewEngine`) always wins over the AdminShared fallback. + +**Tech Stack:** ASP.NET Core MVC Razor views, C# 13, MSTest (existing test +stack, unaffected by this plan — no `.cs` production logic changes, only +`.cshtml` moves and one `.csproj`/one `.cs` file). + +**Spec:** `docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md` + +## Global Constraints + +- No new automated view-rendering test infrastructure (spec, "Non-goals") — + verification is `dotnet build` plus a manual/characterization pass. +- A view with a real functional difference between hosts stays a whole-file, + host-specific override — never split into shared-skeleton-plus-partial + (spec, "Non-goals" — YAGNI unless a specific file proves otherwise during + Task 4, decided per-file, not planned in advance). +- Every moved view must lose its hardcoded `asp-area="@Constants.AreaAdmin"`- + style literal (host-specific `Constants` class, not visible from + `Grand.Web.AdminShared`) in favor of `ViewContext.RouteData.Values["area"]`, + and every moved resource-key lookup that differs by host + (`Admin.*`/`Vendor.*`) must route through the injected + `Scope.ResourceKeyPrefix` (spec, sections 3, 3b). +- Only `Product` views. No other entity's views move in this plan. +- Follow existing repo conventions: `.ai/standards/razor-frontend.md` for + Razor/tag-helper conventions, `.ai/skills/admin-area-changes.md` for + admin-facing view changes. + +--- + +## Task 0: Baseline + +**Files:** none — verification only. + +- [ ] **Step 1: Confirm the branch builds and tests pass before touching views** + +Run: +``` +dotnet build GrandNode.sln +dotnet test src/Tests/Grand.Web.Admin.Tests +dotnet test src/Tests/Grand.Web.Store.Tests +dotnet test src/Tests/Grand.Web.Vendor.Tests +``` +Expected: Build succeeded, all tests PASS. If anything fails here, stop and +fix or report before starting Task 1 — this is the safety net Phase 1 left in +place (per project memory `project_arch001_triple_admin_duplication`, Phase 1 +verified green on 2026-08-17). + +- [ ] **Step 2: Record the current per-host view file counts** + +Run: +``` +find src/Web/Grand.Web.Admin/Areas/Admin/Views/Product -iname "*.cshtml" | wc -l +find src/Web/Grand.Web.Store/Areas/Store/Views/Product -iname "*.cshtml" | wc -l +find src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product -iname "*.cshtml" | wc -l +``` +Expected: 51, 51, 49 — matches the spec's baseline. If different, the repo has +moved since this plan was written; stop and reconcile the plan's Task 4 +checklist against the actual current file list before proceeding. + +--- + +## Task 1: `Grand.Web.AdminShared` becomes a Razor Class Library + +**Files:** +- Modify: `src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj` + +**Interfaces:** none — no consumers exist yet (no `.cshtml` files added until +Task 3). + +- [ ] **Step 1: Change the SDK and add Razor-for-MVC support** + +Current file: +```xml + + + + enable + + ... +``` + +Change the opening `` tag and ``: +```xml + + + + enable + true + + ... +``` +Leave the rest of the file (the `ProjectReference`/`PackageReference` +`ItemGroup`s) unchanged. + +- [ ] **Step 2: Build to confirm the SDK swap alone doesn't break anything** + +Run: +``` +dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj +``` +Expected: all succeed. `Sdk="Microsoft.NET.Sdk.Razor"` with no `.cshtml` files +present yet just adds Razor tooling to the build; it does not require any view +to exist. + +- [ ] **Step 3: Commit** + +```bash +git add src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +git commit -m "Convert Grand.Web.AdminShared to a Razor Class Library (ARCH-001 Phase 2)" +``` + +--- + +## Task 2: `ViewLocationExpander` fallback branch + +**Files:** +- Modify: `src/Web/Grand.Web.Common/View/ViewLocationExpander.cs` +- Test: `src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs` (new file) + +**Interfaces:** +- Produces: `ViewLocationExpander.ExpandViewLocations` gains a second, + independent branch. The classification logic is extracted as an internal + static method `IsAdminSharedController(ActionDescriptor)` so it's unit + testable without a full `ViewLocationExpanderContext`. + +- [ ] **Step 1: Check the existing test project's conventions** + +Run: `find src/Tests/Grand.Web.Common.Tests -iname "*.cs" | head -5` to confirm +the project exists and see its namespace/test-attribute conventions (MSTest, +per `.ai/knowledge/tests.md`). If `View/` doesn't exist as a subfolder yet, +create it alongside the test file in Step 2. + +- [ ] **Step 2: Write the failing tests** + +```csharp +using Grand.Web.Common.View; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.View; + +// Dummy hierarchy standing in for BaseProductController living in +// Grand.Web.AdminShared.Controllers — this test project doesn't reference +// AdminShared, so the namespace string itself is what's under test, not a +// real base type. +namespace Grand.Web.AdminShared.Controllers +{ + public abstract class FakeBaseController { } +} + +public class FakeAdminSharedSubclass : Grand.Web.AdminShared.Controllers.FakeBaseController { } + +public class FakeUnrelatedController { } + +[TestClass] +public class ViewLocationExpanderTests +{ + private static ControllerActionDescriptor DescriptorFor(Type controllerType) => + new() { ControllerTypeInfo = controllerType.GetTypeInfo() }; + + [TestMethod] + public void IsAdminSharedController_TypeDerivesFromAdminSharedControllersNamespace_ReturnsTrue() + { + var descriptor = DescriptorFor(typeof(FakeAdminSharedSubclass)); + Assert.IsTrue(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_UnrelatedType_ReturnsFalse() + { + var descriptor = DescriptorFor(typeof(FakeUnrelatedController)); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_NonControllerActionDescriptor_ReturnsFalse() + { + var descriptor = new ActionDescriptor(); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } +} +``` + +Note: `ControllerTypeInfo.GetTypeInfo()` requires `using System.Reflection;` — +add it to the test file's usings. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `dotnet test src/Tests/Grand.Web.Common.Tests --filter "FullyQualifiedName~ViewLocationExpanderTests"` +Expected: FAIL (compile error — `IsAdminSharedController` doesn't exist yet, +and it isn't `internal`-visible to the test project yet either — see Step 4's +`InternalsVisibleTo` note). + +- [ ] **Step 4: Make `Grand.Web.Common`'s internals visible to its test project** + +Run: `grep -n "InternalsVisibleTo" src/Web/Grand.Web.Common/Grand.Web.Common.csproj` +to check whether this is already wired. If not, add to +`src/Web/Grand.Web.Common/Grand.Web.Common.csproj`'s first `` (or a +new one): +```xml + + + +``` + +- [ ] **Step 5: Implement the expander branch** + +Replace `src/Web/Grand.Web.Common/View/ViewLocationExpander.cs` in full: + +```csharp +using Grand.Web.Common.Themes; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.Extensions.DependencyInjection; + +namespace Grand.Web.Common.View; + +public class ViewLocationExpander : IViewLocationExpander +{ + private const string ThemeKey = "Theme"; + private const string AdminSharedFallbackLocation = "/Views/{1}/{0}.cshtml"; + private const string AdminSharedControllersNamespace = "Grand.Web.AdminShared.Controllers"; + + public void PopulateValues(ViewLocationExpanderContext context) + { + var themeContextFactory = + context.ActionContext.HttpContext.RequestServices.GetRequiredService(); + var themeContext = themeContextFactory.GetThemeContext(context.AreaName ?? ""); + var themeName = themeContext?.GetCurrentTheme(); + if (!string.IsNullOrEmpty(themeName)) + context.Values[ThemeKey] = themeContext.GetCurrentTheme(); + } + + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable viewLocations) + { + if (context.Values.TryGetValue(ThemeKey, out _)) + { + var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); + viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + } + + if (IsAdminSharedController(context.ActionContext.ActionDescriptor)) + viewLocations = viewLocations.Append(AdminSharedFallbackLocation); + + return viewLocations; + } + + /// Whether the executing action's controller type (or any base type) lives in + /// Grand.Web.AdminShared.Controllers. Generic by design — no per-entity base-controller + /// list to maintain: the moment a future Base*Controller (Order, Category, ...) lands in + /// that namespace, its host subclasses get the AdminShared view fallback automatically. + internal static bool IsAdminSharedController(ActionDescriptor descriptor) + { + if (descriptor is not ControllerActionDescriptor cad) return false; + for (var t = cad.ControllerTypeInfo.AsType(); t is not null; t = t.BaseType) + if (t.Namespace == AdminSharedControllersNamespace) + return true; + return false; + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test src/Tests/Grand.Web.Common.Tests --filter "FullyQualifiedName~ViewLocationExpanderTests"` +Expected: PASS (3/3). + +- [ ] **Step 7: Build the three hosts** + +Run: +``` +dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj +``` +Expected: all succeed. The branch is inert until Task 3 gives it a real +`BaseProductController`-derived action and a view to fall back to — this step +only confirms the expander itself compiles and doesn't regress the existing +`ThemeKey` branch (still present, untouched). + +- [ ] **Step 8: Commit** + +```bash +git add src/Web/Grand.Web.Common/View/ViewLocationExpander.cs src/Web/Grand.Web.Common/Grand.Web.Common.csproj src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs +git commit -m "Add AdminShared view fallback branch to ViewLocationExpander (ARCH-001 Phase 2)" +``` + +--- + +## Task 3: Shared `_ViewImports.cshtml` / `_ViewStart.cshtml` + worked pilot view + +This is the template every remaining file in Task 4 follows. Do this one file +fully and correctly before touching any other. + +**Files:** +- Create: `src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml` +- Create: `src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml` +- Create: `src/Web/Grand.Web.AdminShared/Views/Product/TierPriceCreatePopup.cshtml` +- Delete: `src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml` +- Delete: `src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml` +- Delete: `src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml` + +**Interfaces:** +- Consumes: `IAdminDataScope` (Phase 1, DI-registered per host), + `LocService`/`IEnumTranslationService` (existing, see each host's own + `_ViewImports.cshtml`). + +- [ ] **Step 1: Create the shared `_ViewImports.cshtml`** + +```cshtml +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using System.Globalization +@using Microsoft.AspNetCore.Http.Extensions +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using System.Text +@using Grand.SharedKernel.Extensions +@using Grand.Infrastructure +@using Grand.Domain.Common +@using Grand.Domain.Catalog +@using Grand.Domain.Directory +@using Grand.Web.Common +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization +@using Grand.Web.AdminShared.Models.Catalog +@using Grand.Web.AdminShared.Interfaces + +@inject LocService Loc +@inject IEnumTranslationService EnumTranslationService +@inject IAdminDataScope Scope +``` + +- [ ] **Step 2: Create the shared `_ViewStart.cshtml`** + +```cshtml +@{ + var area = Context.GetRouteValue("area")?.ToString(); + Layout = $"~/Areas/{area}/Views/Shared/_{area}Layout.cshtml"; +} +``` + +This resolves each host's own layout by name (`_AdminLayout.cshtml`, +`_StoreLayout.cshtml`, `_VendorLayout.cshtml`) using the current request's +area — see spec section 3a for why `_ViewStart.cshtml` must live here rather +than relying on each host's own (it won't be found for a view resolved +through the AdminShared fallback location). + +- [ ] **Step 3: Read the three current copies to confirm the diff shape** + +Run: +``` +diff -u src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml +diff -u src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml +``` +Expected (confirmed 2026-08-17): Store's copy differs from Admin's only in +`asp-area="@Constants.AreaStore"` vs `@Constants.AreaAdmin`. Vendor's copy +differs in that plus two `Loc["Vendor.Catalog.Products.TierPrices.AddNew"]` +vs `Loc["Admin.Catalog.Products.TierPrices.AddNew"]` resource-key swaps. No +other content differs across all three — this is a pure "trivial unify" case +(spec section 4, row 1). + +- [ ] **Step 4: Write the unified view** + +Create `src/Web/Grand.Web.AdminShared/Views/Product/TierPriceCreatePopup.cshtml`: + +```cshtml +@model ProductModel.TierPriceModel + +@{ + Layout = ""; + + //page title + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} + +
+ +
+
+
+
+
+ + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"] +
+
+
+ +
+
+
+
+ +
+``` + +Note `Layout = ""` (a popup with no chrome) — this file happens not to depend +on the new `_ViewStart.cshtml` from Step 2 at all, but every other Task 4 row +that omits `Layout` does, so `_ViewStart.cshtml` must exist before any row +that relies on it is migrated. It's created in this task so it's already in +place for all of Task 4. + +- [ ] **Step 5: Delete the three host copies** + +```bash +git rm src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml +git rm src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml +git rm src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml +``` + +- [ ] **Step 6: Build all three hosts** + +Run: +``` +dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj +dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj +dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj +``` +Expected: all succeed. A missing `@inject` or bad `@using` in the new +`_ViewImports.cshtml`/the view itself shows up here as a Razor compile error +(this project does compile-time Razor validation as part of `dotnet build`, +not only at first request). + +- [ ] **Step 7: Manual smoke check** + +If a local Kestrel instance is available (per project memory +`reference_running_the_storefront`), open the Admin panel, edit a product, +open its Tier prices tab, click "Add new" — confirm the popup renders with +the correct title, submits, and closes. Repeat once for Store and once for +Vendor (each showing the resource key under their own prefix). If no local +instance is available, note this in the commit message body and rely on the +build success + Task 4/5's aggregate manual pass to catch it. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "Migrate TierPriceCreatePopup to AdminShared, pilot for view consolidation (ARCH-001 Phase 2)" +``` + +--- + +## Task 4: Migrate the remaining Product views + +Same per-row discipline as Phase 1's Task 8/10: one filename per row, each +read across every host that has it, classified, migrated (or left as a +host-specific override), verified with a build, and committed independently. +Follow Task 3's template exactly: same `_ViewImports.cshtml` (already in +place, don't recreate it), same "read all present variants → diff → resolve +`asp-area` literal via `ViewContext.RouteData.Values["area"]` and resource +prefix via `Scope.ResourceKeyPrefix` → write one file in +`Grand.Web.AdminShared/Views/Product/` (or its `Partials/` subfolder, matching +each file's current subfolder) → delete host copies → build → commit" cycle. + +**Files (per row):** +- Create: `src/Web/Grand.Web.AdminShared/Views/Product/.cshtml` (or + `Partials/.cshtml`) — unless the row's classification is "keep as + host override", in which case no AdminShared file is created. +- Delete: the file's copy in each host that currently has it (2 or 3 of + `src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/...`, + `src/Web/Grand.Web.Store/Areas/Store/Views/Product/...`, + `src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/...`) — unless kept as + an override, in which case only the non-kept hosts' copies (if any + duplicate the kept one byte-for-byte) are candidates for deletion; if in + doubt, keep all host copies that currently exist for an override row and + note the ambiguity in the commit message rather than guessing. + +**Known baseline (2026-08-17), `diffAS`/`diffAV` = number of changed diff +lines, Admin-vs-Store / Admin-vs-Vendor, `diff -U0` line count; `presence` = +which hosts currently have the file (Vendor is missing two files entirely). +Recompute for any file if the repo has drifted since — this table is a +starting hint, not a substitute for reading the actual current files:** + +| # | File | Presence | diffAS | diffAV | Starting hint | +|---|---|---|---|---|---| +| 1 | `AssociateProductToAttributeValuePopup.cshtml` | A,S,V | 18 | 34 | small-medium diff, likely area+prefix only — verify | +| 2 | `AssociatedProductAddPopup.cshtml` | A,S,V | 20 | 34 | same shape as #1 | +| 3 | `AttributeCombinationPopup.cshtml` | A,S,V | 10 | 131 | large Admin/Vendor diff — read closely, may have a real difference | +| 4 | `BulkEdit.cshtml` | A,S,V | 21 | 47 | check for the vendor-scoped bulk-edit grid difference (Phase 1 Task 10 note) | +| 5 | `BundleProductAddPopup.cshtml` | A,S,V | 20 | 30 | same shape as #1 | +| 6 | `Create.cshtml` | A,S,V | 2 | 14 | small — likely trivial unify (relies on the shared `_ViewStart.cshtml` for Layout) | +| 7 | `CrossSellProductAddPopup.cshtml` | A,S,V | 18 | 34 | same shape as #1 | +| 8 | `Edit.cshtml` | A,S,V | 4 | 26 | small-medium — check the Store `EditWarningCheck` hook (Phase 1) has a matching view-side warning banner | +| 9 | `List.cshtml` | A,S,V | 141 | 110 | **real functional difference** — Admin has a bulk export/import/delete panel and Store/Vendor filter panel drops `SearchStoreId`/`SearchVendorId`; likely candidate for host-specific override per host, not a single unified file | +| 10 | `Partials/CreateOrUpdate.Additional.cshtml` | A,S,V | 2 | 94 | Admin/Store trivial; Vendor differs a lot — read before assuming unifiable | +| 11 | `Partials/CreateOrUpdate.AssociatedProducts.cshtml` | A,S,V | 10 | 32 | medium | +| 12 | `Partials/CreateOrUpdate.Bids.cshtml` | A,S,V | 6 | 22 | small-medium | +| 13 | `Partials/CreateOrUpdate.BundleProducts.cshtml` | A,S,V | 10 | 32 | medium | +| 14 | `Partials/CreateOrUpdate.Calendar.cshtml` | A,S,V | 12 | 60 | medium-large | +| 15 | `Partials/CreateOrUpdate.Categories.cshtml` | A,S,V | 12 | 54 | medium-large | +| 16 | `Partials/CreateOrUpdate.Collections.cshtml` | A,S,V | 12 | 44 | medium | +| 17 | `Partials/CreateOrUpdate.CrossSells.cshtml` | A,S,V | 8 | 22 | small-medium | +| 18 | `Partials/CreateOrUpdate.Discounts.cshtml` | A,S only | 0 | n/a | **Admin/Store byte-identical, Vendor has no such tab at all** — unify Admin+Store into one AdminShared file; Vendor simply never requests it (Phase 1's Vendor tab set already excludes Discounts) | +| 19 | `Partials/CreateOrUpdate.Documents.cshtml` | A,S only | 8 | n/a | Admin/Store small diff, Vendor has no such tab — same treatment as #18 once Admin/Store diff is resolved | +| 20 | `Partials/CreateOrUpdate.Info.cshtml` | A,S,V | 26 | 160 | **large diff** — read very closely, likely has real per-host fields (e.g. vendor selector shown/hidden) | +| 21 | `Partials/CreateOrUpdate.Inventory.cshtml` | A,S,V | 0 | 20 | Admin/Store byte-identical; Vendor differs — likely unifiable with a `Scope`-driven conditional | +| 22 | `Partials/CreateOrUpdate.Pictures.cshtml` | A,S,V | 8 | 28 | small-medium | +| 23 | `Partials/CreateOrUpdate.Prices.cshtml` | A,S,V | 8 | 83 | medium-large, Vendor differs a lot | +| 24 | `Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml` | A,S,V | 12 | 40 | medium | +| 25 | `Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml` | A,S,V | 20 | 42 | medium | +| 26 | `Partials/CreateOrUpdate.ProductAttributes.cshtml` | A,S,V | 0 | 10 | Admin/Store byte-identical | +| 27 | `Partials/CreateOrUpdate.ProductPrices.cshtml` | A,S,V | 8 | 49 | medium | +| 28 | `Partials/CreateOrUpdate.PurchasedWithOrders.cshtml` | A,S,V | 6 | 52 | medium | +| 29 | `Partials/CreateOrUpdate.Recommended.cshtml` | A,S,V | 8 | 22 | small-medium | +| 30 | `Partials/CreateOrUpdate.RelatedProducts.cshtml` | A,S,V | 10 | 26 | small-medium | +| 31 | `Partials/CreateOrUpdate.Reviews.cshtml` | A,S,V | 6 | 20 | small-medium | +| 32 | `Partials/CreateOrUpdate.SEO.cshtml` | A,S,V | 0 | 4 | tiny — near-trivial | +| 33 | `Partials/CreateOrUpdate.SimilarProducts.cshtml` | A,S,V | 10 | 26 | small-medium | +| 34 | `Partials/CreateOrUpdate.SpecificationAttributes.cshtml` | A,S,V | 10 | 26 | small-medium | +| 35 | `Partials/CreateOrUpdate.cshtml` | A,S,V | 0 | 60 | Admin/Store byte-identical; Vendor differs (likely the tab list itself — fewer tabs for Vendor, e.g. no Discounts/Documents) — read closely, this is the tab-container partial | +| 36 | `Partials/CreateOrUpdateProductAttributeValue.cshtml` | A,S,V | 2 | 10 | small — likely trivial | +| 37 | `Partials/CreateOrUpdateTierPrice.cshtml` | A,S,V | 0 | 22 | Admin/Store byte-identical; Vendor differs | +| 38 | `Partials/ProductAttributes.cshtml` | A,S,V | 0 | 0 | **byte-identical across all three hosts already** — trivial unify, no conditionals needed | +| 39 | `ProductAttributeConditionPopup.cshtml` | A,S,V | 2 | 16 | small | +| 40 | `ProductAttributeMappingPopup.cshtml` | A,S,V | 2 | 10 | small — same shape as Task 3's pilot | +| 41 | `ProductAttributeValidationRulesPopup.cshtml` | A,S,V | 2 | 8 | small — same shape as Task 3's pilot | +| 42 | `ProductAttributeValueCreatePopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot, diff confirmed 2026-08-17 | +| 43 | `ProductAttributeValueEditPopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot | +| 44 | `ProductPicturePopup.cshtml` | A,S,V | 2 | 8 | small | +| 45 | `ProductSpecAttrPopup.cshtml` | A,S,V | 4 | 10 | small | +| 46 | `RecommendedProductAddPopup.cshtml` | A,S,V | 20 | 34 | same shape as #1 | +| 47 | `RelatedProductAddPopup.cshtml` | A,S,V | 18 | 32 | same shape as #1 | +| 48 | `RequiredProductAddPopup.cshtml` | A,S,V | 18 | 52 | medium | +| 49 | `SimilarProductAddPopup.cshtml` | A,S,V | 20 | 32 | same shape as #1 | +| 50 | `TierPriceEditPopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot | + +(`TierPriceCreatePopup.cshtml` is row 0, done in Task 3.) + +- [ ] **Step 1 (repeat per row): read, classify, migrate (or override), build, commit** + +For each row: +1. Read every present host's copy of the file in full. +2. Classify per spec section 4: + - Byte-identical or differs only by area literal / resource prefix already + covered by `Scope.ResourceKeyPrefix` → write one file in AdminShared, + following Task 3's pattern (`ViewContext.RouteData.Values["area"]` for + the area, `Scope.ResourceKeyPrefix` for resource keys), delete host + copies. + - Differs by a capability flag Phase 1 already introduced on the view + model (e.g. a bool controlling whether a field renders) → one file with + an `@if (Model.SomeFlag) { ... }`, delete host copies. + - Real functional difference → leave every existing host copy exactly + where it is; do not touch it, do not create an AdminShared file for it. + `RazorViewEngine`'s host-location-first ordering (Task 2) means these + continue rendering exactly as before with zero code change — this row + is only a documented "leave alone" decision, still worth its own commit + noting why, for the audit trail. +3. Build: `dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj && dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj && dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj && dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj` +4. Commit: +```bash +git add -A +git commit -m "Migrate to AdminShared (ARCH-001 Phase 2)" +# or, for a "leave alone" row: +git commit -m "Keep as host-specific override, no unification (ARCH-001 Phase 2)" --allow-empty +``` + +- [ ] **Step 2: After all 50 rows are checked off, confirm no orphaned host copies remain for unified files** + +Run: +``` +find src/Web/Grand.Web.Admin/Areas/Admin/Views/Product src/Web/Grand.Web.Store/Areas/Store/Views/Product src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product -iname "*.cshtml" | sort +find src/Web/Grand.Web.AdminShared/Views/Product -iname "*.cshtml" | sort +``` +Cross-check against the row-by-row decisions recorded in commit messages — +every file that was unified should no longer exist under any host's own +`Views/Product/`, and every file kept as an override should exist in exactly +the hosts that had it originally (not fewer, not more). + +--- + +## Task 5: Full-solution verification + +**Files:** none — verification only. + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build GrandNode.sln` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 2: Full test run for the affected test projects** + +Run (individually, per project memory `project_test_suite_flaky_parallel` — +not a single solution-wide `dotnet test`): +``` +dotnet test src/Tests/Grand.Web.Common.Tests +dotnet test src/Tests/Grand.Web.Admin.Tests +dotnet test src/Tests/Grand.Web.Store.Tests +dotnet test src/Tests/Grand.Web.Vendor.Tests +``` +Expected: all PASS. + +- [ ] **Step 3: File-count sanity check against the ARCH-001 Phase 2 baseline** + +Run: +``` +find src/Web/Grand.Web.Admin/Areas/Admin/Views/Product -iname "*.cshtml" | wc -l +find src/Web/Grand.Web.Store/Areas/Store/Views/Product -iname "*.cshtml" | wc -l +find src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product -iname "*.cshtml" | wc -l +find src/Web/Grand.Web.AdminShared/Views/Product -iname "*.cshtml" | wc -l +``` +Expected: the three host counts have dropped from the Task 0 baseline +(51/51/49) by however many rows were unified in Task 4; `AdminShared/Views/ +Product` holds that many files (plus 1 for Task 3's pilot). The three host +counts plus the AdminShared count, accounting for files present in more than +one host before migration, should reconcile against Task 4's per-row log — +if they don't, some row was migrated inconsistently; find and fix it before +proceeding. + +- [ ] **Step 4: Manual smoke test** + +If a local Kestrel instance is available (per project memory +`reference_running_the_storefront`): log into each of the three admin panels +and open Product → List → Create → Edit → Save for one existing product per +host. Confirm: +- No `InvalidOperationException: The view '...' was not found` error for any + action. +- Each host's layout/chrome renders correctly (validates the `_ViewStart.cshtml` + fix from Task 3/spec section 3a). +- Host-specific content still appears only where expected (Admin's bulk + export/import/delete panel on `List.cshtml`; Vendor has no + Discounts/Documents tab). +- Popups (tier price, product attribute value, etc.) open, submit, and close + correctly on all three hosts. + +If no local instance is available, report this explicitly as unverified +rather than claiming the pass was done. + +- [ ] **Step 5: Update the ARCH-001 project memory** + +Edit the memory file `project_arch001_triple_admin_duplication.md` (outside +this repo, in the memory directory) to record Phase 2 complete: views +unified, host-specific overrides count, any views deliberately left +un-unified and why, and that the `Grand.Web.AdminShared` + `ViewLocationExpander` +pattern is now proven end-to-end (controller, service, and view layers) and +ready to reuse for the next entity. + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "ARCH-001 Phase 2 complete: Product views consolidated into AdminShared" +``` From 8ca05b816e42f40b5eb7bcabfb7caf5c862dd8fc Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:44:40 +0200 Subject: [PATCH 061/147] Fix plan: split fake namespace types into their own file (Task 2), C# forbids mixing file-scoped and block namespaces --- ...08-17-arch001-phase2-view-consolidation.md | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md b/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md index 258c7941cc..2acb2f8b93 100644 --- a/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md +++ b/docs/superpowers/plans/2026-08-17-arch001-phase2-view-consolidation.md @@ -136,6 +136,7 @@ git commit -m "Convert Grand.Web.AdminShared to a Razor Class Library (ARCH-001 **Files:** - Modify: `src/Web/Grand.Web.Common/View/ViewLocationExpander.cs` - Test: `src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs` (new file) +- Test: `src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs` (new file) **Interfaces:** - Produces: `ViewLocationExpander.ExpandViewLocations` gains a second, @@ -152,26 +153,41 @@ create it alongside the test file in Step 2. - [ ] **Step 2: Write the failing tests** -```csharp -using Grand.Web.Common.View; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Controllers; -using Microsoft.VisualStudio.TestTools.UnitTesting; +C# allows only one namespace per file when using the file-scoped +`namespace X;` form — the fake types standing in for a real +`Grand.Web.AdminShared.Controllers`-namespaced base controller (this test +project has no reference to `Grand.Web.AdminShared`, so a real +`BaseProductController` isn't available; only the namespace string matters +for `IsAdminSharedController`) go in their own file, block-scoped: -namespace Grand.Web.Common.Tests.View; +Create `src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs`: -// Dummy hierarchy standing in for BaseProductController living in -// Grand.Web.AdminShared.Controllers — this test project doesn't reference -// AdminShared, so the namespace string itself is what's under test, not a -// real base type. +```csharp +// Stands in for a real Base*Controller living in Grand.Web.AdminShared.Controllers +// (this test project has no reference to Grand.Web.AdminShared) — only the +// namespace string matters to ViewLocationExpander.IsAdminSharedController. namespace Grand.Web.AdminShared.Controllers { public abstract class FakeBaseController { } } -public class FakeAdminSharedSubclass : Grand.Web.AdminShared.Controllers.FakeBaseController { } +namespace Grand.Web.Common.Tests.View +{ + public class FakeAdminSharedSubclass : Grand.Web.AdminShared.Controllers.FakeBaseController { } -public class FakeUnrelatedController { } + public class FakeUnrelatedController { } +} +``` + +Create `src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs`: + +```csharp +using Grand.Web.Common.View; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.View; [TestClass] public class ViewLocationExpanderTests @@ -304,7 +320,7 @@ only confirms the expander itself compiles and doesn't regress the existing - [ ] **Step 8: Commit** ```bash -git add src/Web/Grand.Web.Common/View/ViewLocationExpander.cs src/Web/Grand.Web.Common/Grand.Web.Common.csproj src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs +git add src/Web/Grand.Web.Common/View/ViewLocationExpander.cs src/Web/Grand.Web.Common/Grand.Web.Common.csproj src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs git commit -m "Add AdminShared view fallback branch to ViewLocationExpander (ARCH-001 Phase 2)" ``` From a0f5d86edf296650aa1d1dae3852854540e1faba Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:47:34 +0200 Subject: [PATCH 062/147] Convert Grand.Web.AdminShared to a Razor Class Library (ARCH-001 Phase 2) --- src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj index 8f965a46fb..88786f07bc 100644 --- a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +++ b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj @@ -1,7 +1,8 @@ - + enable + true From d9ba03806d4bc3ecf823b4aa291c9b43c4b58ea9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 19:52:54 +0200 Subject: [PATCH 063/147] Add AdminShared view fallback branch to ViewLocationExpander (ARCH-001 Phase 2) --- .../AdminSharedControllersNamespaceFakes.cs | 14 ++++++++ .../View/ViewLocationExpanderTests.cs | 35 +++++++++++++++++++ .../Grand.Web.Common/Grand.Web.Common.csproj | 3 ++ .../View/ViewLocationExpander.cs | 31 +++++++++++++--- 4 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs create mode 100644 src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs diff --git a/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs b/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs new file mode 100644 index 0000000000..89748bb9a9 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/View/AdminSharedControllersNamespaceFakes.cs @@ -0,0 +1,14 @@ +// Stands in for a real Base*Controller living in Grand.Web.AdminShared.Controllers +// (this test project has no reference to Grand.Web.AdminShared) — only the +// namespace string matters to ViewLocationExpander.IsAdminSharedController. +namespace Grand.Web.AdminShared.Controllers +{ + public abstract class FakeBaseController { } +} + +namespace Grand.Web.Common.Tests.View +{ + public class FakeAdminSharedSubclass : Grand.Web.AdminShared.Controllers.FakeBaseController { } + + public class FakeUnrelatedController { } +} diff --git a/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs b/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs new file mode 100644 index 0000000000..d6aad6812f --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/View/ViewLocationExpanderTests.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Grand.Web.Common.View; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Common.Tests.View; + +[TestClass] +public class ViewLocationExpanderTests +{ + private static ControllerActionDescriptor DescriptorFor(Type controllerType) => + new() { ControllerTypeInfo = controllerType.GetTypeInfo() }; + + [TestMethod] + public void IsAdminSharedController_TypeDerivesFromAdminSharedControllersNamespace_ReturnsTrue() + { + var descriptor = DescriptorFor(typeof(FakeAdminSharedSubclass)); + Assert.IsTrue(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_UnrelatedType_ReturnsFalse() + { + var descriptor = DescriptorFor(typeof(FakeUnrelatedController)); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } + + [TestMethod] + public void IsAdminSharedController_NonControllerActionDescriptor_ReturnsFalse() + { + var descriptor = new ActionDescriptor(); + Assert.IsFalse(ViewLocationExpander.IsAdminSharedController(descriptor)); + } +} diff --git a/src/Web/Grand.Web.Common/Grand.Web.Common.csproj b/src/Web/Grand.Web.Common/Grand.Web.Common.csproj index 607719f895..53d8c644ad 100644 --- a/src/Web/Grand.Web.Common/Grand.Web.Common.csproj +++ b/src/Web/Grand.Web.Common/Grand.Web.Common.csproj @@ -1,5 +1,8 @@  + + + diff --git a/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs b/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs index 3fcada4617..ff3b04b279 100644 --- a/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs +++ b/src/Web/Grand.Web.Common/View/ViewLocationExpander.cs @@ -1,4 +1,6 @@ -using Grand.Web.Common.Themes; +using Grand.Web.Common.Themes; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Mvc.Razor; using Microsoft.Extensions.DependencyInjection; @@ -7,6 +9,8 @@ namespace Grand.Web.Common.View; public class ViewLocationExpander : IViewLocationExpander { private const string ThemeKey = "Theme"; + private const string AdminSharedFallbackLocation = "/Views/{1}/{0}.cshtml"; + private const string AdminSharedControllersNamespace = "Grand.Web.AdminShared.Controllers"; public void PopulateValues(ViewLocationExpanderContext context) { @@ -21,11 +25,28 @@ public void PopulateValues(ViewLocationExpanderContext context) public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable viewLocations) { - if (!context.Values.TryGetValue(ThemeKey, out var _)) return viewLocations; + if (context.Values.TryGetValue(ThemeKey, out _)) + { + var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); + viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + } - var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); - viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); + if (IsAdminSharedController(context.ActionContext.ActionDescriptor)) + viewLocations = viewLocations.Append(AdminSharedFallbackLocation); return viewLocations; } -} \ No newline at end of file + + /// Whether the executing action's controller type (or any base type) lives in + /// Grand.Web.AdminShared.Controllers. Generic by design — no per-entity base-controller + /// list to maintain: the moment a future Base*Controller (Order, Category, ...) lands in + /// that namespace, its host subclasses get the AdminShared view fallback automatically. + internal static bool IsAdminSharedController(ActionDescriptor descriptor) + { + if (descriptor is not ControllerActionDescriptor cad) return false; + for (var t = cad.ControllerTypeInfo.AsType(); t is not null; t = t.BaseType) + if (t.Namespace == AdminSharedControllersNamespace) + return true; + return false; + } +} From 2fc0868b93b534a210d0dc12f7c149a8b231d2f1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:08:26 +0200 Subject: [PATCH 064/147] Migrate TierPriceCreatePopup to AdminShared, pilot for view consolidation (ARCH-001 Phase 2) --- ...rch001-phase2-view-consolidation-design.md | 76 +++++++++++++++++++ .../Views/Product/TierPriceCreatePopup.cshtml | 11 +-- .../Views/_ViewImports.cshtml | 21 +++++ .../Views/_ViewStart.cshtml | 5 ++ .../Views/Product/TierPriceCreatePopup.cshtml | 61 --------------- .../Views/Product/TierPriceCreatePopup.cshtml | 61 --------------- 6 files changed, 108 insertions(+), 127 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/TierPriceCreatePopup.cshtml (82%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml diff --git a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md index e53f0eeac5..d6d7e93539 100644 --- a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md +++ b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md @@ -147,6 +147,82 @@ Grand.Web (storefront) has no `Grand.Web.AdminShared.Controllers`-derived controllers, and Admin/Store/Vendor have no theme context, so in practice at most one branch ever fires per request. +### 3a. Layout resolution (addendum, found while drafting the pilot task) + +`List.cshtml`/`Create.cshtml`/`Edit.cshtml` (and most other Product views) set +no `Layout` themselves — they inherit it from each host's own +`Areas/{Area}/Views/_ViewStart.cshtml` (e.g. +`src/Web/Grand.Web.Admin/Areas/Admin/Views/_ViewStart.cshtml` sets +`Layout = Constants.Layout_Admin`). Razor's `_ViewStart.cshtml` discovery walks +up from the **resolved logical path the view was found under**, not from the +requesting controller's area. A view resolved through the new fallback +location (`/Views/Product/List.cshtml`, inside `Grand.Web.AdminShared`) walks +up `/Views/Product/` → `/Views/` → `/` looking for `_ViewStart.cshtml` there — +it never sees `/Areas/Admin/Views/_ViewStart.cshtml`, so `Layout` would be left +unset and the page would render with no host chrome. + +Fix: add `src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml`: + +```cshtml +@{ + var area = Context.GetRouteValue("area")?.ToString(); + Layout = $"~/Areas/{area}/Views/Shared/_{area}Layout.cshtml"; +} +``` + +The three hosts' layout files already follow this exact naming convention +(`_AdminLayout.cshtml`, `_StoreLayout.cshtml`, `_VendorLayout.cshtml`, confirmed +via `Constants.Layout_Admin`/`LayoutStore`/`LayoutVendor` in each host's own +`Extensions/Constants.cs`) and stay put in each host — only `Views/Product/*` +moves. The `~/`-rooted path resolves against the full merged view-location +provider (all `ApplicationPart`s, including the executing host's own compiled +views), so it finds the host's own layout correctly regardless of which +assembly the `_ViewStart.cshtml` itself lives in. + +### 3b. `_ViewImports.cshtml` for the shared view folder (addendum) + +Each host's `Areas/{Area}/Views/_ViewImports.cshtml` brings in the tag helpers +and `@inject`s a migrated view needs (`Loc` for resource lookups, +`EnumTranslationService`). The tag helpers Product views actually use +(`admin-input`, `admin-select`, `admin-label`, etc.) come from +`@addTagHelper *, Grand.Web.Common` — already common to all three hosts' +`_ViewImports.cshtml`, not from any host-specific tag helper assembly — so a +single shared import file covers them. Add +`src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml`: + +```cshtml +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using System.Globalization +@using Microsoft.AspNetCore.Http.Extensions +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using System.Text +@using Grand.SharedKernel.Extensions +@using Grand.Infrastructure +@using Grand.Domain.Common +@using Grand.Domain.Catalog +@using Grand.Domain.Directory +@using Grand.Web.Common +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization +@using Grand.Web.AdminShared.Models.Catalog +@using Grand.Web.AdminShared.Interfaces + +@inject LocService Loc +@inject IEnumTranslationService EnumTranslationService +@inject IAdminDataScope Scope +``` + +Injecting `Scope` at the `_ViewImports` level (not per-file) means every +migrated view gets `Scope.ResourceKeyPrefix` (for the `Admin.*`/`Vendor.*` +resource-key split, same as `BaseProductController`'s Phase 1 pattern) and +`ViewContext.RouteData.Values["area"]` (read per-file where an +`asp-area="@Constants.AreaAdmin"` literal needs replacing) without repeating +the `@inject` line in all ~50 files. If a per-file migration needs a tag +helper or using not in this list, add it here rather than to the individual +file, unless it's genuinely single-file-specific. + ### 4. Per-file migration classification For each of the ~53 distinct Product view filenames (union of the three diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/TierPriceCreatePopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/TierPriceCreatePopup.cshtml index 33a3302584..0a022e837f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/TierPriceCreatePopup.cshtml @@ -1,13 +1,14 @@ -@model ProductModel.TierPriceModel +@model ProductModel.TierPriceModel @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
@@ -18,7 +19,7 @@
- @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.AddNew"]
@@ -58,4 +59,4 @@ }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml b/src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..ffa2936d58 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml @@ -0,0 +1,21 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using System.Globalization +@using Microsoft.AspNetCore.Http.Extensions +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using System.Text +@using Grand.SharedKernel.Extensions +@using Grand.Infrastructure +@using Grand.Domain.Common +@using Grand.Domain.Catalog +@using Grand.Domain.Directory +@using Grand.Web.Common +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization +@using Grand.Web.AdminShared.Models.Catalog +@using Grand.Web.AdminShared.Interfaces + +@inject LocService Loc +@inject IEnumTranslationService EnumTranslationService +@inject IAdminDataScope Scope diff --git a/src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml b/src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml new file mode 100644 index 0000000000..7447c721b5 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Routing +@{ + var area = Context.GetRouteValue("area")?.ToString(); + Layout = $"~/Areas/{area}/Views/Shared/_{area}Layout.cshtml"; +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml deleted file mode 100644 index f56e53cdc4..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.AddNew"]; -} - -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml deleted file mode 100644 index 5d87d3aac2..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.TierPrices.AddNew"]; -} - -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file From 6d1324d44e57bd0f6c7b45f7b766a807e910e298 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:13:07 +0200 Subject: [PATCH 065/147] Keep Product/List.cshtml as host-specific override, no unification (ARCH-001 Phase 2) Admin has a bulk export/import/delete panel (ImportExcel modal, ExportExcelAll/Selected, DeleteSelected) and SearchStoreId/SearchVendorId filter fields that Store lacks entirely. Vendor has export/delete (but not import) and its own delete-selected/export-excel-selected forms, plus SearchStoreId/SearchVendorId only in the additionalData() JS (no matching filter UI fields, a pre-existing host quirk, not something to paper over during unification). These are real functional differences in available actions and filter surface per host, not just label/prefix swaps, so each host's List.cshtml is left untouched. From 190125dcacf604dd66c84a2533a8d91444547daf Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:13:40 +0200 Subject: [PATCH 066/147] Keep Product/AttributeCombinationPopup.cshtml as host-specific override, no unification (ARCH-001 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin and Store copies are byte-identical apart from the asp-area literal and the Url.Action area parameter (both use the Admin.* resource prefix already, both inject IStoreService/IGroupService, both render Store/CustomerGroup columns+editors on the tier-price grid). Vendor differs in a real, structural way: it has no @inject IStoreService/IGroupService, builds no allStores/allCustomerGroups JS datasource, and its tierprices-grid has only Quantity/Price columns/editors — the entire Store and CustomerGroup dimension of combination tier pricing is absent for vendors, not just a label/prefix swap. Widget zone names and tag helpers also differ (product_details_attribute_combination_buttons/vc:admin-widget vs vendor_product_details_attribute_combination_buttons/vc:vendor-widget). This is a real functional capability difference, so all three host copies are left untouched. From 00b9aeaad84b4525628dedcdc25cb8671fe8d998 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:14:02 +0200 Subject: [PATCH 067/147] Keep Product/Partials/CreateOrUpdate.Info.cshtml as host-specific override, no unification (ARCH-001 Phase 2) Not just an Admin-vs-Vendor difference: Admin has VendorId, CustomerGroups, and Stores fields that Store's copy already lacks (Store keeps BrandId but drops VendorId/CustomerGroups/ Stores entirely), so even Admin and Store are not unifiable as a single 'trivial prefix swap' file. Vendor differs further and more deeply: no @inject IProductTagService and no product-tags tagEditor resources/JS/field at all, no ShowOnHomePage/BestSeller field or toggle logic (toggleShowOnHomePage is entirely absent), and no downloadable-product toggle machinery (toggleDownloadableProduct and all its IsDownload/UnlimitedDownloads/HasSampleDownload/ HasUserAgreement wiring are missing), plus vc:admin-widget vs vc:vendor-widget zone names. These are real, per-host field/capability differences (what an Admin, a Store owner, and a Vendor are each allowed to edit on a product), not label or prefix swaps, so all three host copies are left untouched. From 9125fb9bf3302cc02ff8b67a63d73576514e48ec Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:14:22 +0200 Subject: [PATCH 068/147] Keep Product/Partials/CreateOrUpdate.Prices.cshtml as host-specific override, no unification (ARCH-001 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin and Store are byte-identical apart from the asp-area literal in Url.Action calls. Vendor differs in real, structural ways: no @inject AdminAreaSettings and no HideStoreColumn-gated Store column, no Store/CustomerGroup columns or grid fields on the tier-price grid at all (vendors cannot scope tier prices by store or customer group), and the entire 'Available Discounts' panel (the @if (Model.AvailableDiscounts...) block with its checkbox list, roughly 30 lines) is absent for Vendor — vendors cannot assign discounts to their products from this view. These are whole missing UI sections/capabilities, not label or prefix swaps, so all three host copies are left untouched. From de545c66ba498e4e55e2c2b4bd59de34dc239a68 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:14:41 +0200 Subject: [PATCH 069/147] Keep Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml as host-specific override, no unification (ARCH-001 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin and Store are byte-identical apart from the asp-area literal in Url.Action calls (both use vc:admin-widget zone names and Admin.* resource keys). Vendor differs in a real, structural way beyond prefix/zone naming: its orders grid drops the OrderStatus, PaymentStatus, and ShippingStatus columns entirely, and its CustomerEmail column is plain text instead of a link through to the customer edit page (vendors cannot view/edit customer records) — vendors see materially less information about which orders a product was purchased with than Admin/Store. This is a real per-host capability/column difference, not a label or prefix swap, so all three host copies are left untouched. From a7066ba5294ecf9823187a68ffa3d6835b386ee1 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:19:23 +0200 Subject: [PATCH 070/147] Migrate Product/Partials/CreateOrUpdate.cshtml to AdminShared (ARCH-001 Phase 2) Admin and Store were byte-identical (tab container listing all Product edit tabs). Vendor's only difference from Admin/Store was which tabs it includes, not any surrounding structure: it omits the Documents tab (permission-gated in Admin/Store via IPermissionService.Authorize(StandardPermission.ManageDocuments)) and the UserFields tab entirely, and uses vc:vendor-widget/vendor_product_details_tabs instead of vc:admin-widget/product_details_tabs for the extension point. Unified into one AdminShared file: Loc keys templated via Scope.ResourceKeyPrefix (same pattern as the Task 3 pilot), the Documents/UserFields tabs and the permission check both gated behind Scope.ResourceKeyPrefix != "Vendor" (short-circuits so Authorize is never called for Vendor, matching original Vendor behavior exactly), and the trailing widget call branches on Scope.ResourceKeyPrefix to emit the same tag helper/zone name each host emitted before. Deleted the three host copies; all four projects (AdminShared, Admin, Store, Vendor) build clean. --- .../Product/Partials/CreateOrUpdate.cshtml | 74 +++++---- .../Product/Partials/CreateOrUpdate.cshtml | 153 ------------------ .../Product/Partials/CreateOrUpdate.cshtml | 129 --------------- 3 files changed, 42 insertions(+), 314 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.cshtml (56%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.cshtml similarity index 56% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.cshtml index 51140f456c..31f8bd4522 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.cshtml @@ -1,52 +1,52 @@ -@using Grand.Business.Core.Interfaces.Common.Security +@using Grand.Business.Core.Interfaces.Common.Security @using Grand.Domain.Permissions @model ProductModel @inject IPermissionService permissionService @{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); + //has "Manage Documents" permission? (Vendor never shows the Documents tab, so skip the check entirely for it) + var canManageDocuments = Scope.ResourceKeyPrefix != "Vendor" && await permissionService.Authorize(StandardPermission.ManageDocuments); }
- +
- +
- +
- +
- +
- +
@@ -54,49 +54,49 @@
- +
- +
- +
- +
- +
- +
- +
@@ -106,7 +106,7 @@
- +
@@ -115,39 +115,49 @@ @if (!string.IsNullOrEmpty(Model.Id)) { - +
- +
- if (canManageDocuments) + @if (Scope.ResourceKeyPrefix != "Vendor") { - + if (canManageDocuments) + { + + +
+ +
+
+
+ } + +
- +
} - - - -
- -
-
-
} - + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 51140f456c..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,153 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Security -@using Grand.Domain.Permissions -@model ProductModel -@inject IPermissionService permissionService -@{ - //has "Manage Documents" permission? - var canManageDocuments = await permissionService.Authorize(StandardPermission.ManageDocuments); -} -
- - - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - - - -
-
-
- - -
- -
-
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - -
- -
-
-
- - -
- -
-
-
- if (canManageDocuments) - { - - -
- -
-
-
- } - - - -
- -
-
-
- } - -
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 642c1c1a7c..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,129 +0,0 @@ -@model ProductModel - -
- - - - - - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- -
-
-
- - -
- - - - -
-
-
- - -
- -
-
-
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - -
- -
-
-
- - -
- -
-
-
- } - -
-
\ No newline at end of file From 8fb8dd60092330d92d29535e19478318f929442c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:26:48 +0200 Subject: [PATCH 071/147] Migrate BulkEdit.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/BulkEdit.cshtml | 70 ++-- .../Areas/Store/Views/Product/BulkEdit.cshtml | 299 ------------------ .../Vendor/Views/Product/BulkEdit.cshtml | 299 ------------------ 3 files changed, 42 insertions(+), 626 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/BulkEdit.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml index 12a09140c9..30c22771a5 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml @@ -1,11 +1,12 @@ -@using System.Text.Encodings.Web +@using System.Text.Encodings.Web @model BulkEditListModel @inject AdminAreaSettings adminAreaSettings @{ //page title - ViewBag.Title = Loc["Admin.Catalog.BulkEdit"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -13,9 +14,16 @@
- @Loc["Admin.Catalog.BulkEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit"]
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
@@ -32,10 +40,10 @@
@@ -68,12 +76,15 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
+ }
@@ -107,18 +118,18 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BulkEditSelect", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditSelect", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData }, update: { - url: "@Html.Raw(Url.Action("BulkEditUpdate", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditUpdate", "Product", new { area = area }))", type: "POST", dataType: "json" }, destroy: { - url: "@Html.Raw(Url.Action("BulkEditDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BulkEditDelete", "Product", new { area = area }))", type: "POST", dataType: "json" }, @@ -198,20 +209,20 @@ scrollable: true, columns: [ { field: "Name", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Name"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Name"]", width: 200 }, { field: "ProductId", - title: "@Loc["Admin.Common.View"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.View"]", width: 80, - template: '@Loc["Admin.Common.View"]' + template: '@Loc[$"{Scope.ResourceKeyPrefix}.Common.View"]' },{ field: "Sku", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Sku"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Sku"]", width: 100 }, { field: "Price", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Price"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Price"]", width: 100, editor: function (container, options) { $('') @@ -223,7 +234,7 @@ } }, { field: "OldPrice", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.OldPrice"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.OldPrice"]", width: 100, editor: function (container, options) { $('') @@ -235,13 +246,13 @@ } }, { field: "StockQuantity", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.StockQuantity"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.StockQuantity"]", //integer format format: "{0:0}", width: 100 }, { field: "ManageInventoryMethod", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.ManageInventoryMethod"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.ManageInventoryMethod"]", width: 150, editor: function (container, options) { $("") @@ -260,11 +271,11 @@ } },{ field: "Published", - title: "@Loc["Admin.Catalog.BulkEdit.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit.Fields.Published"]", width: 90 }, { - command: { name: "destroy", text: "@Loc["Admin.Common.Delete"]" }, - title: "@Loc["Admin.Common.Delete"]", + command: { name: "destroy", text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }, + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]", width: 100 } ] @@ -297,10 +308,13 @@ SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), SearchProductTypeId: $('#SearchProductTypeId').val(), - SearchStoreId: $('#SearchStoreId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + } }; addAntiForgeryToken(data); return data; } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml deleted file mode 100644 index 1a586280c6..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BulkEdit.cshtml +++ /dev/null @@ -1,299 +0,0 @@ -@using System.Text.Encodings.Web -@model BulkEditListModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.BulkEdit"]; -} -
- -
-
- -
-
- - - - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml deleted file mode 100644 index 307256c14e..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BulkEdit.cshtml +++ /dev/null @@ -1,299 +0,0 @@ -@using System.Text.Encodings.Web -@model BulkEditListModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.BulkEdit"]; -} -
- -
-
- -
-
- - - - -
\ No newline at end of file From 0eaf0b100f2b658e62e84d6f43bb5a9c54f89155 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 20:29:14 +0200 Subject: [PATCH 072/147] Migrate CreateOrUpdate.Additional.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Additional.cshtml | 178 +++++++------ .../Partials/CreateOrUpdate.Additional.cshtml | 252 ------------------ .../Partials/CreateOrUpdate.Additional.cshtml | 165 ------------ 3 files changed, 98 insertions(+), 497 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Additional.cshtml (59%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Additional.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml similarity index 59% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml index 4e74e29ead..63b8cb1aba 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml @@ -1,10 +1,18 @@ -@using Grand.Domain.Media +@using Grand.Domain.Media @model ProductModel @{ ViewData["DownloadType"] = DownloadType.Product; ViewData["ReferenceId"] = Model.Id; + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +@if (Scope.ResourceKeyPrefix == "Vendor") +{ + +} +else +{ + } -
@@ -67,92 +75,95 @@
-
-
-
- -
- - +@if (Scope.ResourceKeyPrefix != "Vendor") +{ +
+
+
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
+}
@@ -208,12 +219,12 @@ - @Loc["Admin.Catalog.Products.Fields.RequiredProductIds.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.AddNew"] - - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml deleted file mode 100644 index 0216a0c329..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ /dev/null @@ -1,165 +0,0 @@ -@using Grand.Domain.Media -@model ProductModel -@{ - ViewData["DownloadType"] = DownloadType.Product; - ViewData["ReferenceId"] = Model.Id; -} - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
- -
- - -
-
-
- -
- - - - - @Loc["Vendor.Catalog.Products.Fields.RequiredProductIds.AddNew"] - - - - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file From 28dee1f97b999fe1377a8dd584d86ba5ec4e4564 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:20:02 +0200 Subject: [PATCH 073/147] Migrate CreateOrUpdate.Calendar.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Calendar.cshtml | 81 ++-- .../Partials/CreateOrUpdate.Calendar.cshtml | 376 ------------------ .../Partials/CreateOrUpdate.Calendar.cshtml | 376 ------------------ 3 files changed, 49 insertions(+), 784 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml index d1aa6cae86..e36ed28382 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml @@ -1,12 +1,22 @@ -@model ProductModel +@model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) { - + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
-

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

+

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Calendar.Calendarconfiguration"]

@@ -15,7 +25,7 @@
- +
@@ -28,7 +38,7 @@
- +
@@ -75,44 +85,44 @@
-
@Loc["Admin.Catalog.Products.Calendar.WeekDay"]
+
@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Calendar.WeekDay"]
@@ -123,15 +133,22 @@
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + } -} -else -{ -
- @Loc["Admin.Catalog.Products.Calendar.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml deleted file mode 100644 index b5fc4df0d5..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ /dev/null @@ -1,376 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - -
-

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

-
-
-
- -
- -
-
- -
- -
-
- -
-
- -
- -
-
- -
- -
-
-
-
- -
- -
-
-
-
- -
- - - - -
-
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
-
@Loc["Vendor.Catalog.Products.Calendar.WeekDay"]
-
-
-
-
- - - - - - - -
-
-
-
-
-
-
- -
-
-
-
- - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.Calendar.SaveBeforeEdit"] -
-} \ No newline at end of file From 279b5b28df423f53bca50c38d6d68ab03f88761a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:20:13 +0200 Subject: [PATCH 074/147] Fix IAdminDataScope registration collision when hosts share one process Grand.Web references Grand.Web.Admin, Grand.Web.Store, and Grand.Web.Vendor simultaneously (all three run in one process there). Each host's own StartupApplication registered its own IAdminDataScope with plain AddScoped; the last one to run (by Priority) silently won for the whole process, so Grand.Web always resolved VendorProductDataScope regardless of which area actually served the request. Admin/Store requests then crashed with a NullReferenceException in DefaultVendorId (no CurrentVendor for a non-vendor login). Fixed by registering all three concrete scope classes under their own types and adding a single IAdminDataScope registration (RoutedProductDataScope, in Grand.Web.AdminShared) that resolves the correct one per-request from the current route's area value. Verified: dotnet build on AdminShared/Admin/Store/Vendor/Grand.Web (the combined host where the bug reproduced) all succeed; Admin.Tests 411/411, Store.Tests 32/32, Vendor.Tests 8/8 pass with no regressions. --- .../Startup/StartupApplication.cs | 7 +-- .../Services/RoutedProductDataScope.cs | 63 +++++++++++++++++++ .../Startup/StartupApplication.cs | 12 ++++ .../Startup/StartupApplication.cs | 7 +-- .../Startup/StartupApplication.cs | 9 ++- 5 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs diff --git a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs index 4031d95093..23cdc2c441 100644 --- a/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Admin/Startup/StartupApplication.cs @@ -1,10 +1,7 @@ using elFinder.Net.AspNetCore.Extensions; using elFinder.Net.Drivers.FileSystem.Extensions; -using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.Admin.Infrastructure; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Services; using Grand.Web.Common.View; namespace Grand.Web.Admin.Startup; @@ -14,7 +11,9 @@ public class StartupApplication : IStartupApplication public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { services.AddScoped(); - services.AddScoped, GlobalAdminDataScope>(); + // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own + // StartupApplication via RoutedProductDataScope - see its doc comment. Registering it here + // too would race with Store's/Vendor's registrations under the combined Grand.Web host. } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs new file mode 100644 index 0000000000..0e9131504a --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedProductDataScope.cs @@ -0,0 +1,63 @@ +#nullable enable + +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host implementation at +/// request time, based on the current request's "area" route value, instead of relying on +/// DI registration order. +/// +/// Why this exists: Grand.Web (the combined host, run as "grand-web" in Aspire) references +/// Grand.Web.Admin, Grand.Web.Store, and Grand.Web.Vendor together in one process/one DI +/// container. Each host's own StartupApplication used to register +/// AddScoped<IAdminDataScope<Product>, X>() directly - plain AddScoped doesn't replace an +/// earlier registration, it appends one, so whichever host's StartupApplication ran last (by +/// IStartupApplication.Priority - Vendor's is highest) silently won for every area in that +/// process. That surfaced as a NullReferenceException in VendorProductDataScope.DefaultVendorId +/// when an Admin user opened the product list under the combined host, because Vendor's scope +/// assumes WorkContext.CurrentVendor is set. +/// +/// Fix: register the three concrete scopes as themselves (not as IAdminDataScope<Product>) +/// and register this resolver as the single IAdminDataScope<Product> - see +/// Grand.Web.AdminShared/Startup/StartupApplication.cs. Each host's own StartupApplication no +/// longer registers IAdminDataScope<Product> at all. +/// +public class RoutedProductDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreAdminDataScope storeScope, + VendorProductDataScope vendorScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Vendor" => vendorScope, + "Store" => storeScope, + //covers "Admin" and the (unexpected) case of no area route value at all - same + //fallback GlobalAdminDataScope always represented before this resolver existed + _ => globalScope + }; + } + } + + public Task HasAccess(Product entity) => Resolved.HasAccess(entity); + + public Task CanView(Product entity) => Resolved.CanView(entity); + + public IQueryable ApplyScope(IQueryable query) => Resolved.ApplyScope(query); + + public string? DefaultStoreId => Resolved.DefaultStoreId; + + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + + public string? DefaultVendorId => Resolved.DefaultVendorId; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 38ec7c761e..51937c430c 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -1,5 +1,6 @@ using elFinder.Net.AspNetCore.Extensions; using elFinder.Net.Drivers.FileSystem.Extensions; +using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Services; @@ -58,6 +59,17 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); + // IAdminDataScope: registered once here (not per-host) via a route-driven resolver. + // Grand.Web (the combined host) references Admin, Store, and Vendor together in one DI + // container, so three competing AddScoped, X>() calls (one per host's + // own StartupApplication) would just have the last-registered host silently win for every + // area - see RoutedProductDataScope's doc comment for the NullReferenceException this caused. + // The three concrete scopes are registered as themselves so the resolver can pick between them + // per-request based on the "area" route value. + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped(); + services.AddScoped, RoutedProductDataScope>(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Store/Startup/StartupApplication.cs b/src/Web/Grand.Web.Store/Startup/StartupApplication.cs index d7103717d5..ff01045b75 100644 --- a/src/Web/Grand.Web.Store/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Store/Startup/StartupApplication.cs @@ -1,7 +1,4 @@ -using Grand.Domain.Catalog; using Grand.Infrastructure; -using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Services; namespace Grand.Web.Store.Startup; @@ -9,7 +6,9 @@ public class StartupApplication : IStartupApplication { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { - services.AddScoped, StoreAdminDataScope>(); + // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own + // StartupApplication via RoutedProductDataScope - see its doc comment. Registering it here + // too would race with Admin's/Vendor's registrations under the combined Grand.Web host. } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs index 510afb9ef8..a0da106c1d 100644 --- a/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Vendor/Startup/StartupApplication.cs @@ -1,5 +1,4 @@ using Grand.Data; -using Grand.Domain.Catalog; using Grand.Infrastructure; using Grand.Web.Vendor.Interfaces; using Grand.Web.Vendor.Services; @@ -13,7 +12,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config if (!DataSettingsManager.DatabaseIsInstalled()) return; - services.AddScoped, Grand.Web.AdminShared.Services.VendorProductDataScope>(); + // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own + // StartupApplication via RoutedProductDataScope - see its doc comment. A plain + // AddScoped, VendorProductDataScope>() here used to win for every + // area under the combined Grand.Web host (Vendor's StartupApplication has the highest + // Priority, so its registration ran last and silently overrode Admin's/Store's), causing a + // NullReferenceException in VendorProductDataScope.DefaultVendorId whenever an Admin/Store + // user opened the product list under that host. // IProductViewModelService is registered by Grand.Web.AdminShared's own StartupApplication // (Grand.Web.AdminShared/Startup/StartupApplication.cs), which is discovered and run for this // host too via the IStartupApplication assembly scan in StartupBase, since Vendor references From 4c32d0c5ded25deb105987c9c98471809eb3d1ae Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 21:30:26 +0200 Subject: [PATCH 075/147] Migrate CreateOrUpdate.Categories.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Categories.cshtml | 82 ++++++--- .../Partials/CreateOrUpdate.Categories.cshtml | 170 ------------------ .../Partials/CreateOrUpdate.Categories.cshtml | 164 ----------------- 3 files changed, 55 insertions(+), 361 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Categories.cshtml (66%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Categories.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Categories.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Categories.cshtml similarity index 66% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Categories.cshtml index 00ddb47365..5b25188203 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Categories.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Categories.cshtml @@ -1,17 +1,33 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- @Loc["Admin.Catalog.Products.Categories.Fields.Category"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Categories.Fields.Category"]
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
-} -else -{ -
- @Loc["Admin.Catalog.Products.Categories.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Categories.cshtml deleted file mode 100644 index caa71acdd2..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Categories.cshtml +++ /dev/null @@ -1,164 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Vendor.Catalog.Products.Categories.Fields.Category"] -
-
-
-
- -
- -} -else -{ -
- @Loc["Vendor.Catalog.Products.Categories.SaveBeforeEdit"] -
-} \ No newline at end of file From 3c0d1d63ba6b117631f1cade5493502234cd402e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 21:32:31 +0200 Subject: [PATCH 076/147] Migrate CreateOrUpdate.Collections.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.Collections.cshtml | 81 ++++++--- .../CreateOrUpdate.Collections.cshtml | 170 ------------------ .../CreateOrUpdate.Collections.cshtml | 162 ----------------- 3 files changed, 55 insertions(+), 358 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Collections.cshtml (68%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Collections.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Collections.cshtml similarity index 68% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Collections.cshtml index 87a9e42f4a..3127283479 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Collections.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Collections.cshtml @@ -1,15 +1,32 @@ -@model ProductModel +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- @Loc["Admin.Catalog.Products.Collections.Fields.Collection"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.Collection"]
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
@@ -19,25 +36,25 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ProductCollectionList", "Product", new { productId = Model.Id, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ProductCollectionList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, create: { - url: "@Html.Raw(Url.Action("ProductCollectionInsert", "Product", new { productId = Model.Id, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ProductCollectionInsert", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, update: { - url:"@Html.Raw(Url.Action("ProductCollectionUpdate", "Product", new { area = Constants.AreaAdmin }))", + url:"@Html.Raw(Url.Action("ProductCollectionUpdate", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("ProductCollectionDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ProductCollectionDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -79,7 +96,7 @@ previousNext: false, info: false }, - toolbar: [{ name: "create", text: "@Loc["Admin.Common.AddNewRecord"]" }], + toolbar: [{ name: "create", text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.AddNewRecord"]" }], edit: function(e) { if (e.model.isNew()) { e.model.CollectionId = ""; @@ -92,22 +109,34 @@ scrollable: false, columns: [{ field: "CollectionId", - title: "@Loc["Admin.Catalog.Products.Collections.Fields.Collection"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.Collection"]", width: 200, editor: collectionDropDownEditor, - template: '#:Collection#' + @if (Scope.ResourceKeyPrefix == "Vendor") + { + template: '#:Collection#' + } + else + { + template: '#:Collection#' + } }, + @if (Scope.ResourceKeyPrefix != "Vendor") { - field: "IsFeaturedProduct", - title: "@Loc["Admin.Catalog.Products.Collections.Fields.IsFeaturedProduct"]", - width: 100, - headerAttributes: { style: "text-align:center" }, - attributes: { style: "text-align:center" }, - template: '# if(IsFeaturedProduct) {# #} else {# #} #' - }, + + { + field: "IsFeaturedProduct", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.IsFeaturedProduct"]", + width: 100, + headerAttributes: { style: "text-align:center" }, + attributes: { style: "text-align:center" }, + template: '# if(IsFeaturedProduct) {# #} else {# #} #' + }, + + } { field: "DisplayOrder", - title: "@Loc["Admin.Catalog.Products.Collections.Fields.DisplayOrder"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.DisplayOrder"]", width: 100, minScreenWidth: 500, headerAttributes: { style: "text-align:center" }, @@ -118,13 +147,13 @@ command: [{ name: "edit", text: { - edit: "@Loc["Admin.Common.Edit"]", - update: "@Loc["Admin.Common.Update"]", - cancel: "@Loc["Admin.Common.Cancel"]" + edit: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Edit"]", + update: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Update"]", + cancel: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Cancel"]" } }, { name: "destroy", - text: "@Loc["Admin.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }], width: 200 }] @@ -148,7 +177,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("Collection", "Search", new { area = Constants.AreaAdmin }))" + url: "@Html.Raw(Url.Action("Collection", "Search", new { area = area }))" } }, schema: { @@ -165,6 +194,6 @@ else {
- @Loc["Admin.Catalog.Products.Collections.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.SaveBeforeEdit"]
-} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml deleted file mode 100644 index 932bdf4439..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Collections.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Admin.Catalog.Products.Collections.Fields.Collection"] -
-
-
-
- -
- - - -} -else -{ -
- @Loc["Admin.Catalog.Products.Collections.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Collections.cshtml deleted file mode 100644 index a11b521a6b..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Collections.cshtml +++ /dev/null @@ -1,162 +0,0 @@ -@model ProductModel -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Vendor.Catalog.Products.Collections.Fields.Collection"] -
-
-
-
- -
- - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.Collections.SaveBeforeEdit"] -
-} \ No newline at end of file From 16cdbd444520f1f269ba1b6741f0559fc86ce1f6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 21:34:35 +0200 Subject: [PATCH 077/147] Migrate RequiredProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/RequiredProductAddPopup.cshtml | 55 +++--- .../Product/RequiredProductAddPopup.cshtml | 172 ------------------ .../Product/RequiredProductAddPopup.cshtml | 172 ------------------ 3 files changed, 31 insertions(+), 368 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/RequiredProductAddPopup.cshtml (79%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/RequiredProductAddPopup.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/RequiredProductAddPopup.cshtml index 301c2f7397..35a55dd265 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RequiredProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/RequiredProductAddPopup.cshtml @@ -1,9 +1,10 @@ -@model ProductModel.AddRequiredProductModel +@model ProductModel.AddRequiredProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.Choose"]; }
@@ -12,7 +13,7 @@
- @Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.RequiredProductIds.Choose"]
@@ -30,13 +31,13 @@
- +
@@ -61,18 +62,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -94,8 +98,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -120,7 +127,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RequiredProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RequiredProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -152,15 +159,15 @@ scrollable: false, columns: [{ field: "Name", - title: "@Loc["Admin.Common.Select"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Select"]", width: 50, - template: '' + template: '' },{ field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -183,4 +190,4 @@ }); -
\ No newline at end of file +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml deleted file mode 100644 index dbcd9502ea..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RequiredProductAddPopup.cshtml +++ /dev/null @@ -1,172 +0,0 @@ -@model ProductModel.AddRequiredProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Fields.RequiredProductIds.Choose"]; -} - -
-
- -
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml deleted file mode 100644 index 0dc56e0554..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RequiredProductAddPopup.cshtml +++ /dev/null @@ -1,172 +0,0 @@ -@model ProductModel.AddRequiredProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.Fields.RequiredProductIds.Choose"]; -} - -
-
- -
- -
\ No newline at end of file From ce0068bf2557678713f3a154fd487ead96667ce3 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 21:36:20 +0200 Subject: [PATCH 078/147] Migrate CreateOrUpdate.Discounts.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Discounts.cshtml | 4 +-- .../Partials/CreateOrUpdate.Discounts.cshtml | 25 ------------------- 2 files changed, 2 insertions(+), 27 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml (95%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml index a15fb8b3d0..8380a9dd4f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml @@ -1,4 +1,4 @@ -@model ProductModel +@model ProductModel @if (Model.AvailableDiscounts is { Count: > 0 }) { @@ -22,4 +22,4 @@ else @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"])
} - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml deleted file mode 100644 index a15fb8b3d0..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml +++ /dev/null @@ -1,25 +0,0 @@ -@model ProductModel - -@if (Model.AvailableDiscounts is { Count: > 0 }) -{ -
- @foreach (var discount in Model.AvailableDiscounts) - { - - } -
-} -else -{ -
- @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"]) -
-} - \ No newline at end of file From ce5123c597816b102636944957fede7fe0361084 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 21:37:44 +0200 Subject: [PATCH 079/147] Migrate CreateOrUpdate.Documents.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Documents.cshtml | 15 ++-- .../Partials/CreateOrUpdate.Documents.cshtml | 71 ------------------- 2 files changed, 9 insertions(+), 77 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Documents.cshtml (85%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Documents.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml similarity index 85% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml index b8d7292a28..58cd8cc948 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Documents.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml @@ -1,12 +1,15 @@ -@model ProductModel +@model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
@@ -17,7 +20,7 @@ $(document).ready(function () { dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ListDocuments", "Document", new { reference = (int)Reference.Product, ObjectId = Model.Id, StatusId = -1, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ListDocuments", "Document", new { reference = (int)Reference.Product, ObjectId = Model.Id, StatusId = -1, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -51,12 +54,12 @@ $(document).ready(function () { columns: [{ field: "Number", title: "@Loc["Admin.Documents.Document.Fields.Number"]", - template: '#=kendo.htmlEncode(Number)#', + template: '#=kendo.htmlEncode(Number)#', }, { field: "Name", title: "@Loc["Admin.Documents.Document.Fields.Name"]", - template: '#=kendo.htmlEncode(Name)#', + template: '#=kendo.htmlEncode(Name)#', }, { field: "Published", title: "@Loc["Admin.Documents.Document.Fields.Published"]", @@ -68,4 +71,4 @@ $(document).ready(function () { }] }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Documents.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Documents.cshtml deleted file mode 100644 index f042cd9100..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Documents.cshtml +++ /dev/null @@ -1,71 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -
- -
-
-
- - -
- - \ No newline at end of file From 0d01a32a0710a66f99c4c63e305445cf05242068 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:09 +0200 Subject: [PATCH 080/147] Migrate AssociateProductToAttributeValuePopup.cshtml to AdminShared (ARCH-001 Phase 2) --- ...sociateProductToAttributeValuePopup.cshtml | 55 +++-- ...sociateProductToAttributeValuePopup.cshtml | 207 ------------------ ...sociateProductToAttributeValuePopup.cshtml | 207 ------------------ 3 files changed, 31 insertions(+), 438 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/AssociateProductToAttributeValuePopup.cshtml (79%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/AssociateProductToAttributeValuePopup.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/AssociateProductToAttributeValuePopup.cshtml index c6204bb478..dbf06b79d7 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/AssociateProductToAttributeValuePopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel +@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; } -
@@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]
@@ -33,10 +34,10 @@
@@ -63,18 +64,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -131,8 +135,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -149,7 +156,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("AssociateProductToAttributeValuePopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("AssociateProductToAttributeValuePopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -187,15 +194,15 @@ scrollable: false, columns: [{ field: "Name", - title: "@Loc["Admin.Common.Select"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Select"]", width: 50, - template: '' + template: '' },{ field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -218,4 +225,4 @@ }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml deleted file mode 100644 index 22cf8fa004..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ /dev/null @@ -1,207 +0,0 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; -} - -
-
-
- -
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml deleted file mode 100644 index 4b11a5dad1..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociateProductToAttributeValuePopup.cshtml +++ /dev/null @@ -1,207 +0,0 @@ -@model ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; -} - -
-
-
- -
-
- - -
\ No newline at end of file From f3d9b009f9353ea33b5641f3d017e3d34a8a05d5 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:14 +0200 Subject: [PATCH 081/147] Migrate AssociatedProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/AssociatedProductAddPopup.cshtml | 57 +++-- .../Product/AssociatedProductAddPopup.cshtml | 223 ------------------ .../Product/AssociatedProductAddPopup.cshtml | 223 ------------------ 3 files changed, 32 insertions(+), 471 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/AssociatedProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/AssociatedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/AssociatedProductAddPopup.cshtml index adac34c4c9..186039b2f8 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AssociatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/AssociatedProductAddPopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.AddAssociatedProductModel +@model ProductModel.AddAssociatedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]; } -
@@ -18,7 +19,7 @@
- @Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]
@@ -36,13 +37,13 @@
- +
@@ -67,18 +68,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -131,15 +135,18 @@ $('.filter-text-close').toggle(); }); - + function additionalData() { var data = { SearchProductName: $('#@Html.IdFor(model => model.SearchProductName)').val(), SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -156,7 +163,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("AssociatedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("AssociatedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -197,11 +204,11 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]", - template: "#:Name# # if(AssociatedToProductName !== null) {#
@Loc["Admin.Catalog.Products.Fields.AssociatedToProductName"]: #:AssociatedToProductName# #} #" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]", + template: "#:Name# # if(AssociatedToProductName !== null) {#
@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.AssociatedToProductName"]: #:AssociatedToProductName# #} #" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -234,4 +241,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml deleted file mode 100644 index fb283c0d9a..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AssociatedProductAddPopup.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@model ProductModel.AddAssociatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AssociatedProducts.AddNew"]; -} - -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml deleted file mode 100644 index d32d7fcab3..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AssociatedProductAddPopup.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@model ProductModel.AddAssociatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.AssociatedProducts.AddNew"]; -} - -
- - -
-
- -
-
- -
\ No newline at end of file From 79543f0672fb580055863972c06ef836b3a05976 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:19 +0200 Subject: [PATCH 082/147] Migrate BundleProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/BundleProductAddPopup.cshtml | 53 +++-- .../Product/BundleProductAddPopup.cshtml | 206 ----------------- .../Product/BundleProductAddPopup.cshtml | 208 ------------------ 3 files changed, 30 insertions(+), 437 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/BundleProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/BundleProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/BundleProductAddPopup.cshtml index 80ae8d6493..e299760f87 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/BundleProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/BundleProductAddPopup.cshtml @@ -1,12 +1,13 @@ -@model ProductModel.AddBundleProductModel +@model ProductModel.AddBundleProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.BundleProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.BundleProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -122,7 +126,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("BundleProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("BundleProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -161,10 +165,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -204,8 +208,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val() + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val() + } }; addAntiForgeryToken(data); return data; @@ -217,4 +224,4 @@ $('#mastercheckbox').prop('checked', numChkBoxes == numChkBoxesChecked && numChkBoxes > 0); } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml deleted file mode 100644 index 02395a9ffa..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/BundleProductAddPopup.cshtml +++ /dev/null @@ -1,206 +0,0 @@ -@model ProductModel.AddBundleProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.BundleProducts.AddNew"]; -} -
- -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml deleted file mode 100644 index 24e8c7d5c4..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/BundleProductAddPopup.cshtml +++ /dev/null @@ -1,208 +0,0 @@ -@model ProductModel.AddBundleProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.BundleProducts.AddNew"]; -} -
- -
-
- -
-
- -
\ No newline at end of file From b0b4cb99fd302a47d0fbf4894ba3a0623c316baa Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:23 +0200 Subject: [PATCH 083/147] Migrate Create.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/Create.cshtml | 26 ++++++++----- .../Areas/Store/Views/Product/Create.cshtml | 37 ------------------- .../Areas/Vendor/Views/Product/Create.cshtml | 37 ------------------- 3 files changed, 17 insertions(+), 83 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Create.cshtml (54%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml similarity index 54% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml index 718f3ce5b8..30c4253909 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml @@ -1,9 +1,10 @@ -@model ProductModel +@model ProductModel @{ //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AddNew"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -11,20 +12,27 @@
- @Loc["Admin.Catalog.Products.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AddNew"] - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") + @Html.ActionLink(Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BackToList"], "List")
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
@@ -34,4 +42,4 @@
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml deleted file mode 100644 index 0ff5e79e49..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Create.cshtml +++ /dev/null @@ -1,37 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Catalog.Products.AddNew"] - - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") - -
-
-
- - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml deleted file mode 100644 index d5f41e079a..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Create.cshtml +++ /dev/null @@ -1,37 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.AddNew"] - - @Html.ActionLink(Loc["Vendor.Catalog.Products.BackToList"], "List") - -
-
-
- - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file From fff0c265512a6c6c5c5056e2e94da078115e46de Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:28 +0200 Subject: [PATCH 084/147] Migrate CrossSellProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/CrossSellProductAddPopup.cshtml | 57 +++-- .../Product/CrossSellProductAddPopup.cshtml | 214 ------------------ .../Product/CrossSellProductAddPopup.cshtml | 214 ------------------ 3 files changed, 32 insertions(+), 453 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/CrossSellProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/CrossSellProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/CrossSellProductAddPopup.cshtml index 2b5c6b71cb..83b81c433f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/CrossSellProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/CrossSellProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddCrossSellProductModel +@model ProductModel.AddCrossSellProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.CrossSells.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.CrossSells.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -124,15 +128,18 @@ } }); }); - + function additionalData() { var data = { SearchProductName: $('#@Html.IdFor(model => model.SearchProductName)').val(), SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -148,7 +155,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("CrossSellProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("CrossSellProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -180,7 +187,7 @@ scrollable: false, columns: [{ field: "Id", - title: "@Loc["Admin.Common.Check"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Check"]", headerTemplate: "", headerAttributes: { style: "text-align:center" }, template: "", @@ -188,10 +195,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -225,4 +232,4 @@ }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml deleted file mode 100644 index 28a4c1a9b7..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/CrossSellProductAddPopup.cshtml +++ /dev/null @@ -1,214 +0,0 @@ -@model ProductModel.AddCrossSellProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.CrossSells.AddNew"]; -} -
- - -
-
- -
-
- - -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml deleted file mode 100644 index 373e62eb2c..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/CrossSellProductAddPopup.cshtml +++ /dev/null @@ -1,214 +0,0 @@ -@model ProductModel.AddCrossSellProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.CrossSells.AddNew"]; -} -
- - -
-
- -
-
- - -
\ No newline at end of file From 02bf8b151a98a9c5f3c3cc78e259d1ec496ee5cf Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:30:32 +0200 Subject: [PATCH 085/147] Migrate Edit.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/Edit.cshtml | 38 ++++--- .../Areas/Store/Views/Product/Edit.cshtml | 100 ------------------ .../Areas/Vendor/Views/Product/Edit.cshtml | 100 ------------------ 3 files changed, 23 insertions(+), 215 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Edit.cshtml (71%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml similarity index 71% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml index 8a24cb767c..40a78defe9 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml @@ -1,9 +1,10 @@ -@model ProductModel +@model ProductModel @{ //page title - ViewBag.Title = Loc["Admin.Catalog.Products.EditProductDetails"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.EditProductDetails"]; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
+
@@ -11,31 +12,38 @@
- @Loc["Admin.Catalog.Products.EditProductDetails"] - @Model.Name + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.EditProductDetails"] - @Model.Name - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") + @Html.ActionLink(Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BackToList"], "List")
- @Loc["Admin.Common.Delete"] + @Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"] - + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
@@ -49,7 +57,7 @@ @@ -90,11 +98,11 @@ window.kendoWindow({ modal: true, width: "400px", - title: "@Loc["Admin.Catalog.Products.Copy"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Copy"]", actions: ["Close"] }); } window.data('kendoWindow').center().open(); }); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml deleted file mode 100644 index 9a1a7e6bde..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Edit.cshtml +++ /dev/null @@ -1,100 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.EditProductDetails"]; -} - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.EditProductDetails"] - @Model.Name - - @Html.ActionLink(Loc["Admin.Catalog.Products.BackToList"], "List") - -
-
-
- - - - - - @Loc["Admin.Common.Delete"] - - -
-
-
-
- -
-
-
-
- - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml deleted file mode 100644 index c53b8eb7c9..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Edit.cshtml +++ /dev/null @@ -1,100 +0,0 @@ -@model ProductModel -@{ - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.EditProductDetails"]; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.EditProductDetails"] - @Model.Name - - @Html.ActionLink(Loc["Vendor.Catalog.Products.BackToList"], "List") - -
-
-
- - - - - - @Loc["Vendor.Common.Delete"] - - -
-
-
-
- -
-
-
-
-
- - - - \ No newline at end of file From 0cdeafa45350b607d77832cdf9de0d2cc8c28b1b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:34:16 +0200 Subject: [PATCH 086/147] Migrate CreateOrUpdate.AssociatedProducts.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.AssociatedProducts.cshtml | 54 ++++--- .../CreateOrUpdate.AssociatedProducts.cshtml | 142 ------------------ .../CreateOrUpdate.AssociatedProducts.cshtml | 142 ------------------ 3 files changed, 35 insertions(+), 303 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml (71%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml similarity index 71% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml index e895f68f6e..935591ac97 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml @@ -1,22 +1,38 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
-

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

+

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.Note2"]

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

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

-
- - - - -} -else -{ - @Loc["Vendor.Catalog.Products.AssociatedProducts.SaveBeforeEdit"] -} \ No newline at end of file From 8625051c58f4e295e09156642addaa6942e69acc Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:34:21 +0200 Subject: [PATCH 087/147] Migrate CreateOrUpdate.Bids.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Bids.cshtml | 43 ++++++--- .../Partials/CreateOrUpdate.Bids.cshtml | 96 ------------------- .../Partials/CreateOrUpdate.Bids.cshtml | 96 ------------------- 3 files changed, 30 insertions(+), 205 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Bids.cshtml (65%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Bids.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Bids.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Bids.cshtml similarity index 65% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Bids.cshtml index 9f69a153a3..5b40f6618a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Bids.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Bids.cshtml @@ -1,15 +1,32 @@ -@model ProductModel +@model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
-} -else -{ -
- @Loc["Admin.Catalog.Products.Bids.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Bids.cshtml deleted file mode 100644 index 6b4a5fba07..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Bids.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - -
- -
-
-
- -
- -} -else -{ -
- @Loc["Vendor.Catalog.Products.Bids.SaveBeforeEdit"] -
-} \ No newline at end of file From 25d5a8e7bc3ee3035940bb1792f8f0830862a7d8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:34:26 +0200 Subject: [PATCH 088/147] Migrate CreateOrUpdate.BundleProducts.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.BundleProducts.cshtml | 54 ++++--- .../CreateOrUpdate.BundleProducts.cshtml | 149 ------------------ .../CreateOrUpdate.BundleProducts.cshtml | 149 ------------------ 3 files changed, 35 insertions(+), 317 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml (72%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml similarity index 72% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml index d024ae10d2..c2f1f6ef2f 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml @@ -1,20 +1,36 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- - -} -else -{ -
- @Loc["Admin.Catalog.Products.BundleProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml deleted file mode 100644 index 63c4c4c22d..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ /dev/null @@ -1,149 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.BundleProducts.SaveBeforeEdit"] -
-} \ No newline at end of file From a2e9802f72037d2b1486d57c32b24c6b3b285505 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:34:31 +0200 Subject: [PATCH 089/147] Migrate CreateOrUpdate.CrossSells.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.CrossSells.cshtml | 44 ++++--- .../Partials/CreateOrUpdate.CrossSells.cshtml | 116 ------------------ .../Partials/CreateOrUpdate.CrossSells.cshtml | 116 ------------------ 3 files changed, 30 insertions(+), 246 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml (71%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml similarity index 71% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml index e0e5d1d25a..6ffd4b6a96 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml @@ -1,20 +1,36 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- @Loc["Admin.Catalog.Products.CrossSells"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells"]
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- - -} -else -{ -
- @Loc["Admin.Catalog.Products.CrossSells.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml deleted file mode 100644 index bb1365bbb5..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.CrossSells.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Vendor.Catalog.Products.CrossSells"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.CrossSells.SaveBeforeEdit"] -
-} \ No newline at end of file From f70a06b173e06d41063468c946b58062c0c2feed Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:37:25 +0200 Subject: [PATCH 090/147] Migrate CreateOrUpdate.Inventory.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Inventory.cshtml | 36 ++- .../Partials/CreateOrUpdate.Inventory.cshtml | 278 ------------------ .../Partials/CreateOrUpdate.Inventory.cshtml | 278 ------------------ 3 files changed, 25 insertions(+), 567 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml (89%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml similarity index 89% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml index fc89e14d67..e413c67190 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml @@ -1,5 +1,12 @@ -@model ProductModel - +@model ProductModel +@if (Scope.ResourceKeyPrefix == "Vendor") +{ + +} +else +{ + +}
@@ -35,16 +42,16 @@ - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] @@ -76,18 +83,18 @@

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

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

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

} else { - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] }
@@ -275,4 +282,11 @@
- \ No newline at end of file +@if (Scope.ResourceKeyPrefix == "Vendor") +{ + +} +else +{ + +} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml deleted file mode 100644 index fc89e14d67..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ /dev/null @@ -1,278 +0,0 @@ -@model ProductModel - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- @if (Model.ProductWarehouseInventoryModels.Count > 0) - { - - - - - - - - - - - @for (var i = 0; i < Model.ProductWarehouseInventoryModels.Count; i++) - { - var item = Model.ProductWarehouseInventoryModels[i]; - - - - - - - } - -
- @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] -
-
- @item.WarehouseName (@item.WarehouseCode) - -
-
- - - - - -
-

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

-

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

-

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

- } - else - { - @Loc["Admin.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] - } -
-
-
- -
- - - - - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- [@Model.BaseWeightIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml deleted file mode 100644 index 37a5f2909f..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Inventory.cshtml +++ /dev/null @@ -1,278 +0,0 @@ -@model ProductModel - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- @if (Model.ProductWarehouseInventoryModels.Count > 0) - { - - - - - - - - - - - @for (var i = 0; i < Model.ProductWarehouseInventoryModels.Count; i++) - { - var item = Model.ProductWarehouseInventoryModels[i]; - - - - - - - } - -
- @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse"] - - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.WarehouseUsed"] - - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.StockQuantity"] - - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.ReservedQuantity"] -
-
- @item.WarehouseName (@item.WarehouseCode) - -
-
- - - - - -
-

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

-

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

-

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

- } - else - { - @Loc["Vendor.Catalog.Products.ProductWarehouseInventory.Fields.Warehouse.NotDefined"] - } -
-
-
- -
- - - - - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- [@Model.BaseWeightIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- [@Model.BaseDimensionIn] - -
-
-
- -
- - -
-
-
-
- \ No newline at end of file From 0058ffcc25c40366fa7eaca7e85676c7288fdd1e Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:37:29 +0200 Subject: [PATCH 091/147] Migrate CreateOrUpdate.Pictures.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Pictures.cshtml | 52 ++++-- .../Partials/CreateOrUpdate.Pictures.cshtml | 165 ------------------ .../Partials/CreateOrUpdate.Pictures.cshtml | 165 ------------------ 3 files changed, 34 insertions(+), 348 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml (76%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml similarity index 76% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml index 74961f4335..30f1e492de 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml @@ -1,13 +1,29 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }

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

@@ -133,7 +149,7 @@ @{ ViewData["Reference"] = "Product"; ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaAdmin }); + ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = area }); ViewData["Click"] = "btnRefreshProductPictures"; }
@@ -160,6 +176,6 @@ else {
- @Loc["Admin.Catalog.Products.Pictures.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Pictures.SaveBeforeEdit"]
-} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml deleted file mode 100644 index 0681495121..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ /dev/null @@ -1,165 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- -
- - -

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

-
-
-
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaStore }); - ViewData["Click"] = "btnRefreshProductPictures"; - } -
- - -
- - -
-
-
-} -else -{ -
- @Loc["Admin.Catalog.Products.Pictures.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml deleted file mode 100644 index ea70f54d19..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ /dev/null @@ -1,165 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
-
-
- -
- - -

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

-
-
-
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = Constants.AreaVendor }); - ViewData["Click"] = "btnRefreshProductPictures"; - } -
- - -
- - -
-
-
-} -else -{ -
- @Loc["Vendor.Catalog.Products.Pictures.SaveBeforeEdit"] -
-} \ No newline at end of file From 2908cfec8548d9b6ef0626b1fb50a7fff7bc81fe Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:40:15 +0200 Subject: [PATCH 092/147] Migrate CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml to AdminShared (ARCH-001 Phase 2) --- ...Attributes.TabAttributeCombinations.cshtml | 57 +++-- ...Attributes.TabAttributeCombinations.cshtml | 231 ------------------ ...Attributes.TabAttributeCombinations.cshtml | 231 ------------------ 3 files changed, 37 insertions(+), 482 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml (74%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml similarity index 74% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml index ad33ef35ce..d665fb2e99 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml @@ -1,25 +1,42 @@ @model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}

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

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

- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml deleted file mode 100644 index 0725feba05..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ /dev/null @@ -1,231 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings -
-

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

-

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

-
-
- -
-
-
- - -
- - - - \ No newline at end of file From 956ec576839687d9c69615474d190b53e8f8ade7 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Mon, 17 Aug 2026 22:40:20 +0200 Subject: [PATCH 093/147] Migrate CreateOrUpdate.ProductAttributes.TabAttributes.cshtml to AdminShared (ARCH-001 Phase 2) --- ...ate.ProductAttributes.TabAttributes.cshtml | 60 ++-- ...ate.ProductAttributes.TabAttributes.cshtml | 273 ------------------ ...ate.ProductAttributes.TabAttributes.cshtml | 273 ------------------ 3 files changed, 38 insertions(+), 568 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml (79%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml index ea9eb19c0b..33de3ae3eb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml @@ -1,13 +1,29 @@ @model ProductModel - +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
- + @if (Scope.ResourceKeyPrefix == "Vendor") + { + + } + else + { + + }
@@ -30,13 +46,13 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ProductAttributeMappingList", "Product", new { productId = Model.Id, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ProductAttributeMappingList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("ProductAttributeMappingDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("ProductAttributeMappingDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -117,16 +133,16 @@ columns: [ { field: "ProductAttributeId", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Fields.Attribute"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Fields.Attribute"]", width: 190, - template: "#=kendo.htmlEncode(ProductAttribute)#
" + template: "#=kendo.htmlEncode(ProductAttribute)#
" }, { field: "TextPrompt", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Fields.TextPrompt"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Fields.TextPrompt"]", width: 120 }, { field: "IsRequired", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Fields.IsRequired"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Fields.IsRequired"]", width: 50, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -134,21 +150,21 @@ }, { field: "AttributeControlTypeId", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Fields.AttributeControlType"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Fields.AttributeControlType"]", width: 140, - template: "#:AttributeControlType#
# if(ValidationRulesAllowed) {# @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules.Link"]
#=ValidationRulesString# #} #" + template: "#:AttributeControlType#
# if(ValidationRulesAllowed) {# @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.ValidationRules.Link"]
#=ValidationRulesString# #} #" }, { field: "ConditionAllowed", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]", width: 80, - template: "# if(ConditionAllowed) {# @Loc["Admin.Common.Edit"]
#} #" + template: "# if(ConditionAllowed) {# @Loc[$"{Scope.ResourceKeyPrefix}.Common.Edit"]
#} #" }, { command: [ { name: "destroy", - text: "@Loc["Admin.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" } ], width: 50 @@ -161,13 +177,13 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ProductAttributeValueList", "Product", new { area = Constants.AreaAdmin }))?productAttributeMappingId=" + e.data.Id + "&productId=@Model.Id", + url: "@Html.Raw(Url.Action("ProductAttributeValueList", "Product", new { area = area }))?productAttributeMappingId=" + e.data.Id + "&productId=@Model.Id", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("ProductAttributeValueDelete", "Product", new { area = Constants.AreaAdmin }))?pam=" + e.data.Id + "&productId=@Model.Id", + url: "@Html.Raw(Url.Action("ProductAttributeValueDelete", "Product", new { area = area }))?pam=" + e.data.Id + "&productId=@Model.Id", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -222,7 +238,7 @@ }, toolbar: [ { - template: "@Loc["Admin.Common.AddNew"]" + template: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.AddNew"]" } ], scrollable: false, @@ -240,11 +256,11 @@ columns: [ { field: "Name", - title: "@Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name"]", - template: "#=Name#" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.Name"]", + template: "#=Name#" }, { - command: { name: "destroy", text: "@Loc["Admin.Common.Delete"]" }, - title: "@Loc["Admin.Common.Delete"]" + command: { name: "destroy", text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }, + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" } ] }); diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml deleted file mode 100644 index 1588b30f59..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ /dev/null @@ -1,273 +0,0 @@ -@model ProductModel - - - - - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml deleted file mode 100644 index 0422baa127..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ /dev/null @@ -1,273 +0,0 @@ -@model ProductModel - - - - - - - \ No newline at end of file From e3a89bb4d69c6b42f5c6124a31e504c81078dc42 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 05:48:46 +0200 Subject: [PATCH 094/147] Spec addendum: widget-zone selection uses per-area partial files, not an inline @if (ARCH-001 Phase 2) --- ...rch001-phase2-view-consolidation-design.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md index d6d7e93539..272b2f2bfc 100644 --- a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md +++ b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md @@ -258,6 +258,63 @@ subagent per row. incidentally (e.g. if a `.cshtml` move breaks a `[ViewComponent]` or model binding), not a substitute for the manual pass above. +### 4a. Widget-zone selection: per-area partial files, not an inline `@if` (addendum, 2026-08-18) + +Early Task 4 batches unified views containing a widget-zone tag-helper call +(`` vs ``) using an inline conditional: + +```cshtml +@if (Scope.ResourceKeyPrefix == "Vendor") +{ + +} +else +{ + +} +``` + +Superseded. Widget-zone selection now uses a small, per-occurrence partial +resolved through the same host-override-wins mechanism section 3 already +established, instead of a C# branch inside the unified file: + +- `src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone..cshtml` + holds the Admin/Store-shared default: ``. +- `src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone..cshtml` + holds Vendor's override: ``. +- The parent unified view calls `` in place of the old `@if` block. Admin and Store naturally + fall through to the AdminShared default (no host-specific file needed for + them, since they share it); Vendor's own `Areas/Vendor/...` copy is found + first by `RazorViewEngine` and wins, exactly like any other host override + under section 3 — no new expander logic needed. +- `` is a short, occurrence-specific PascalCase name derived from the + zone-name pair with the common `product_`/`vendor_product_` prefix and any + `vendor_` prefix stripped (e.g. `product_bulk_edit_buttons` / + `vendor_product_bulk_edit_buttons` → `WidgetZone.BulkEditButtons.cshtml`; + `product_details_bids_top` / `vendor_product_details_bids_top` → + `WidgetZone.Bids.Top.cshtml`). Pick a name that reads clearly next to its + sibling occurrences in the same parent file (e.g. `Bids.Top`/`Bids.Bottom` + for the two zones inside `CreateOrUpdate.Bids.cshtml`). + +Rationale: `Scope.ResourceKeyPrefix`-branching was already established for +genuinely mixed content (a whole tab present in some hosts, per section 3's +`CreateOrUpdate.cshtml` case) where no other mechanism fits cleanly. For +widget-zone selection specifically — a single self-contained tag-helper call +repeated at ~20+ sites — a per-area file keeps each host's markup physically +separate and lets the existing view-resolution precedence do the selection, +rather than growing every unified file's branch count. This also means a +future widget-zone-only change to one host never touches the shared parent +file at all. + +All files already unified under the old inline-`@if` pattern get retrofitted +to this one in the same implementation pass that introduces it (tracked in +the plan's Task 4 as a one-time batch), so the codebase never carries both +patterns side by side once that pass lands. + ## Out of scope - Automated view-rendering tests (`WebApplicationFactory`, Testcontainers-backed From efb870be6280c4c90a1d691036cadb26f43af115 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 06:01:04 +0200 Subject: [PATCH 095/147] Retrofit BulkEdit.cshtml widget-zone selection to per-area partial files (ARCH-001 Phase 2) --- .../Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml | 9 +-------- .../Product/Partials/WidgetZone.BulkEditButtons.cshtml | 1 + .../Product/Partials/WidgetZone.BulkEditButtons.cshtml | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml index 30c22771a5..b9c11fe4f4 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/BulkEdit.cshtml @@ -16,14 +16,7 @@ @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.BulkEdit"]
- @if (Scope.ResourceKeyPrefix == "Vendor") - { - - } - else - { - - } +
diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 0000000000..4ba491b7e2 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 0000000000..0cb4f72941 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + From f32c314c3cbc7f5f4b637a35592744ffbcc60a56 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 06:01:08 +0200 Subject: [PATCH 096/147] Retrofit Create.cshtml widget-zone selection to per-area partial files (ARCH-001 Phase 2) --- .../Grand.Web.AdminShared/Views/Product/Create.cshtml | 9 +-------- .../Product/Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Product/Partials/WidgetZone.DetailsButtons.cshtml | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml index 30c4253909..392252abbe 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Create.cshtml @@ -25,14 +25,7 @@ - @if (Scope.ResourceKeyPrefix == "Vendor") - { - - } - else - { - - } +
diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..2f59d184d9 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..16625af2e1 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1 @@ + From 117f659c1679e00db2e332fd4a63a737839c0dd7 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 06:04:18 +0200 Subject: [PATCH 097/147] Retrofit Edit.cshtml widget-zone selection to per-area partial files (ARCH-001 Phase 2) --- src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml index 40a78defe9..8a81ae02ac 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Edit.cshtml @@ -36,14 +36,7 @@ @Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"] - @if (Scope.ResourceKeyPrefix == "Vendor") - { - - } - else - { - - } +
From a83be070056ab1d94f9b3c65fd58b47ef905afbb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 06:08:48 +0200 Subject: [PATCH 098/147] Retrofit CreateOrUpdate.Additional.cshtml widget-zone selection to per-area partial files (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Additional.cshtml | 18 ++---------------- .../WidgetZone.Additional.Bottom.cshtml | 1 + .../Partials/WidgetZone.Additional.Top.cshtml | 1 + .../WidgetZone.Additional.Bottom.cshtml | 1 + .../Partials/WidgetZone.Additional.Top.cshtml | 1 + 5 files changed, 6 insertions(+), 16 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Top.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml index 63b8cb1aba..37088da81a 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Additional.cshtml @@ -5,14 +5,7 @@ ViewData["ReferenceId"] = Model.Id; var area = ViewContext.RouteData.Values["area"]?.ToString(); } -@if (Scope.ResourceKeyPrefix == "Vendor") -{ - -} -else -{ - -} +
@@ -260,11 +253,4 @@ else
-@if (Scope.ResourceKeyPrefix == "Vendor") -{ - -} -else -{ - -} + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 0000000000..b2bea2c08d --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 0000000000..d9af4164c2 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 0000000000..3d204ba76c --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 0000000000..6cc0b2dc49 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1 @@ + From 5cd268cc15c84d34dadc7b9f3414e38861907513 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 06:08:54 +0200 Subject: [PATCH 099/147] Retrofit CreateOrUpdate.AssociatedProducts.cshtml widget-zone selection to per-area partial files (ARCH-001 Phase 2) --- .../CreateOrUpdate.AssociatedProducts.cshtml | 18 ++---------------- ...WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../WidgetZone.AssociatedProducts.Top.cshtml | 1 + ...WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../WidgetZone.AssociatedProducts.Top.cshtml | 1 + 5 files changed, 6 insertions(+), 16 deletions(-) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml index 935591ac97..3d1fadaf04 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml @@ -8,14 +8,7 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.Note2"]

- @if (Scope.ResourceKeyPrefix == "Vendor") - { - - } - else - { - - } +
@@ -25,14 +18,7 @@
- @if (Scope.ResourceKeyPrefix == "Vendor") - { - - } - else - { - - } +
- } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml new file mode 100644 index 0000000000..fd6f81b0ee --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml @@ -0,0 +1,145 @@ +@using Grand.Business.Core.Interfaces.Common.Directory +@model ProductModel +@inject ICurrencyService currencyService +@{ + var currencies = await currencyService.GetAllCurrencies(); + var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); + currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} +@if (!string.IsNullOrEmpty(Model.Id) && currencies.Count > 0) +{ +
+
+
+
+
+ +} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml deleted file mode 100644 index 3a2355db76..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ /dev/null @@ -1,147 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Directory -@model ProductModel -@inject ICurrencyService currencyService -@{ - var currencies = await currencyService.GetAllCurrencies(); - var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); - currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); -} -@if (!string.IsNullOrEmpty(Model.Id) && currencies.Count > 0) -{ - if (currencies.Count > 0) - { -
-
-
-
-
- - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml deleted file mode 100644 index b22e91491b..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ /dev/null @@ -1,144 +0,0 @@ -@using Grand.Business.Core.Interfaces.Common.Directory -@model ProductModel -@inject ICurrencyService currencyService -@{ - var currencies = await currencyService.GetAllCurrencies(); - var defaultCurrency = await currencyService.GetPrimaryStoreCurrency(); - currencies = currencies.Where(x => x.Id != defaultCurrency.Id).ToList(); -} -@if (!string.IsNullOrEmpty(Model.Id) && currencies.Any()) -{ -
-
-
-
-
- -} \ No newline at end of file From 748661884b6d85935d566720db9481e65cab03fb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:21:33 +0200 Subject: [PATCH 113/147] Migrate CreateOrUpdate.Recommended.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.Recommended.cshtml | 28 +++-- .../WidgetZone.Recommended.Bottom.cshtml | 1 + .../WidgetZone.Recommended.Top.cshtml | 1 + .../CreateOrUpdate.Recommended.cshtml | 116 ------------------ .../CreateOrUpdate.Recommended.cshtml | 116 ------------------ .../WidgetZone.Recommended.Bottom.cshtml | 1 + .../WidgetZone.Recommended.Top.cshtml | 1 + 7 files changed, 19 insertions(+), 245 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml (79%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml index e5d748de03..3a225905e5 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml @@ -1,20 +1,22 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- +
- @Loc["Admin.Catalog.Products.Recommended"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended"]
- +
- - -} -else -{ -
- @Loc["Admin.Catalog.Products.Recommended.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml deleted file mode 100644 index 1cc94c6716..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Recommended.cshtml +++ /dev/null @@ -1,116 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
- -
- @Loc["Vendor.Catalog.Products.Recommended"] -
-
-
-
- - -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.Recommended.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 0000000000..9239fad113 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 0000000000..fc738ef76c --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1 @@ + From c0eccc464d000d07cb67b80ad3faf06809d6cd72 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:21:38 +0200 Subject: [PATCH 114/147] Migrate CreateOrUpdate.RelatedProducts.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.RelatedProducts.cshtml | 32 ++-- .../CreateOrUpdate.RelatedProducts.cshtml | 142 ------------------ .../CreateOrUpdate.RelatedProducts.cshtml | 142 ------------------ 3 files changed, 17 insertions(+), 299 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml (79%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml index 7b402d6bfd..db0f9c50b4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml @@ -1,17 +1,19 @@ -@model ProductModel - +@model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- @Loc["Admin.Catalog.Products.RelatedProducts"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts"]
@@ -35,19 +37,19 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RelatedProductList", "Product", new { productId = Model.Id, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RelatedProductList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, update: { - url:"@Html.Raw(Url.Action("RelatedProductUpdate", "Product", new { area = Constants.AreaAdmin }))", + url:"@Html.Raw(Url.Action("RelatedProductUpdate", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("RelatedProductDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RelatedProductDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -94,11 +96,11 @@ scrollable: false, columns: [{ field: "Product2Name", - title: "@Loc["Admin.Catalog.Products.RelatedProducts.Fields.Product"]", - template: '#=kendo.htmlEncode(Product2Name)#', + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.Fields.Product"]", + template: '#=kendo.htmlEncode(Product2Name)#', }, { field: "DisplayOrder", - title: "@Loc["Admin.Catalog.Products.RelatedProducts.Fields.DisplayOrder"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.Fields.DisplayOrder"]", //integer format format: "{0:0}", width: 120, @@ -108,13 +110,13 @@ command: [{ name: "edit", text: { - edit: "@Loc["Admin.Common.Edit"]", - update: "@Loc["Admin.Common.Update"]", - cancel: "@Loc["Admin.Common.Cancel"]" + edit: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Edit"]", + update: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Update"]", + cancel: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Cancel"]" } }, { name: "destroy", - text: "@Loc["Admin.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }] }] }); @@ -137,6 +139,6 @@ else {
- @Loc["Admin.Catalog.Products.RelatedProducts.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.SaveBeforeEdit"]
} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml deleted file mode 100644 index 2f81db741b..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Admin.Catalog.Products.RelatedProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.RelatedProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml deleted file mode 100644 index d53a659b2f..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Vendor.Catalog.Products.RelatedProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.RelatedProducts.SaveBeforeEdit"] -
-} \ No newline at end of file From e739fa4a732cd8e57f5fb77eea11ac486ebc54ef Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:27:30 +0200 Subject: [PATCH 115/147] Migrate CreateOrUpdate.Reviews.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.Reviews.cshtml | 41 ++++++--- .../Partials/WidgetZone.Reviews.Bottom.cshtml | 2 + .../Partials/WidgetZone.Reviews.Top.cshtml | 2 + .../Partials/CreateOrUpdate.Reviews.cshtml | 86 ------------------- .../Partials/CreateOrUpdate.Reviews.cshtml | 86 ------------------- .../Partials/WidgetZone.Reviews.Bottom.cshtml | 2 + .../Partials/WidgetZone.Reviews.Top.cshtml | 2 + 7 files changed, 37 insertions(+), 184 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml (62%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml similarity index 62% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml index 5869caccc3..e3122522c5 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml @@ -1,12 +1,15 @@ -@model ProductModel +@model ProductModel @inject AdminAreaSettings adminAreaSettings +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
- +
- +
\ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 0000000000..457da7c516 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 0000000000..9423f693eb --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml deleted file mode 100644 index 51f45a66fa..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ /dev/null @@ -1,86 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings - -
- -
-
-
- -
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml deleted file mode 100644 index 8fce92cf40..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ /dev/null @@ -1,86 +0,0 @@ -@model ProductModel -@inject AdminAreaSettings adminAreaSettings - -
- -
-
-
- -
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 0000000000..5704aea051 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 0000000000..e75d6cdf31 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + From 1ba00e026bb06611c82d551c3cc2bbf62ed09595 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:29:08 +0200 Subject: [PATCH 116/147] Migrate CreateOrUpdate.SEO.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdate.SEO.cshtml | 6 +- .../Partials/WidgetZone.SEO.Bottom.cshtml | 2 + .../Partials/WidgetZone.SEO.Top.cshtml | 2 + .../Partials/CreateOrUpdate.SEO.cshtml | 74 ------------------- .../Partials/CreateOrUpdate.SEO.cshtml | 74 ------------------- .../Partials/WidgetZone.SEO.Bottom.cshtml | 2 + .../Partials/WidgetZone.SEO.Top.cshtml | 2 + 7 files changed, 11 insertions(+), 151 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.SEO.cshtml (94%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Top.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SEO.cshtml similarity index 94% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SEO.cshtml index 509467fe03..1fc2f59903 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SEO.cshtml @@ -1,6 +1,6 @@ -@using Microsoft.AspNetCore.Mvc.Razor +@using Microsoft.AspNetCore.Mvc.Razor @model ProductModel - + @{ Func @@ -71,4 +71,4 @@
- \ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 0000000000..7932bdf55d --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 0000000000..1f49d9a74c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml deleted file mode 100644 index 509467fe03..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ /dev/null @@ -1,74 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml deleted file mode 100644 index 213f234ee2..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SEO.cshtml +++ /dev/null @@ -1,74 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
- -
; -} - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 0000000000..28fa53d0b8 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 0000000000..4703a53cd7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + From 707e9a57c8a4a50a9356ebeb5f981a70e30645b6 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:30:37 +0200 Subject: [PATCH 117/147] Migrate CreateOrUpdate.SimilarProducts.cshtml to AdminShared (ARCH-001 Phase 2) --- .../CreateOrUpdate.SimilarProducts.cshtml | 29 ++-- .../CreateOrUpdate.SimilarProducts.cshtml | 142 ------------------ .../CreateOrUpdate.SimilarProducts.cshtml | 142 ------------------ 3 files changed, 16 insertions(+), 297 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml (79%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml similarity index 79% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml index 79f2ffd1ef..c982a474f2 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml @@ -1,17 +1,20 @@ @model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) {
- @Loc["Admin.Catalog.Products.SimilarProducts"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts"]
@@ -35,19 +38,19 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("SimilarProductList", "Product", new { productId = Model.Id, area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("SimilarProductList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, update: { - url:"@Html.Raw(Url.Action("SimilarProductUpdate", "Product", new { area = Constants.AreaAdmin }))", + url:"@Html.Raw(Url.Action("SimilarProductUpdate", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("SimilarProductDelete", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("SimilarProductDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -94,11 +97,11 @@ scrollable: false, columns: [{ field: "Product2Name", - title: "@Loc["Admin.Catalog.Products.SimilarProducts.Fields.Product"]", - template: '#=kendo.htmlEncode(Product2Name)#', + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.Fields.Product"]", + template: '#=kendo.htmlEncode(Product2Name)#', }, { field: "DisplayOrder", - title: "@Loc["Admin.Catalog.Products.SimilarProducts.Fields.DisplayOrder"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.Fields.DisplayOrder"]", //integer format format: "{0:0}", width: 120, @@ -108,13 +111,13 @@ command: [{ name: "edit", text: { - edit: "@Loc["Admin.Common.Edit"]", - update: "@Loc["Admin.Common.Update"]", - cancel: "@Loc["Admin.Common.Cancel"]" + edit: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Edit"]", + update: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Update"]", + cancel: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Cancel"]" } }, { name: "destroy", - text: "@Loc["Admin.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" }] }] }); @@ -137,6 +140,6 @@ else {
- @Loc["Admin.Catalog.Products.SimilarProducts.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.SaveBeforeEdit"]
} \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml deleted file mode 100644 index 511a2fe099..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Admin.Catalog.Products.SimilarProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SimilarProducts.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml deleted file mode 100644 index c6f1c40a6c..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@model ProductModel - - -@if (!string.IsNullOrEmpty(Model.Id)) -{ -
-
- @Loc["Vendor.Catalog.Products.SimilarProducts"] -
-
-
-
- -
- - - - -} -else -{ -
- @Loc["Vendor.Catalog.Products.SimilarProducts.SaveBeforeEdit"] -
-} \ No newline at end of file From 8cf623c5099ff927848160eeb2228d49b8eb7bca Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:32:14 +0200 Subject: [PATCH 118/147] Migrate CreateOrUpdate.SpecificationAttributes.cshtml to AdminShared (ARCH-001 Phase 2) --- ...ateOrUpdate.SpecificationAttributes.cshtml | 159 ------------------ ...ateOrUpdate.SpecificationAttributes.cshtml | 29 ++-- ...Zone.SpecificationAttributes.Bottom.cshtml | 2 + ...getZone.SpecificationAttributes.Top.cshtml | 2 + ...ateOrUpdate.SpecificationAttributes.cshtml | 159 ------------------ ...Zone.SpecificationAttributes.Bottom.cshtml | 2 + ...getZone.SpecificationAttributes.Top.cshtml | 2 + 7 files changed, 24 insertions(+), 331 deletions(-) delete mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml rename src/Web/{Grand.Web.Vendor/Areas/Vendor => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml (81%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml deleted file mode 100644 index 509401a68a..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ /dev/null @@ -1,159 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml similarity index 81% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml index e4a587b9e0..3fb6bf6fcc 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml @@ -1,15 +1,18 @@ @model ProductModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @if (!string.IsNullOrEmpty(Model.Id)) { @@ -34,13 +37,13 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("ProductSpecAttrList", "Product", new { productId = Model.Id, area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("ProductSpecAttrList", "Product", new { productId = Model.Id, area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken }, destroy: { - url: "@Html.Raw(Url.Action("ProductSpecAttrDelete", "Product", new { area = Constants.AreaVendor }))", + url: "@Html.Raw(Url.Action("ProductSpecAttrDelete", "Product", new { area = area }))", type: "POST", dataType: "json", data: addAntiForgeryToken @@ -108,22 +111,22 @@ scrollable: false, columns: [{ field: "AttributeTypeName", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.AttributeType"]", - template: "#=kendo.htmlEncode(AttributeTypeName)#" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.AttributeType"]", + template: "#=kendo.htmlEncode(AttributeTypeName)#" }, { field: "ValueRaw", encoded: false, - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.Value"]", - template: "#=kendo.htmlEncode(AttributeName)# : #=ValueRaw# " + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.Value"]", + template: "#=kendo.htmlEncode(AttributeName)# : #=ValueRaw# " }, { field: "AllowFiltering", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.AllowFiltering"]", headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(AllowFiltering) {# #} else {# #} #' }, { field: "ShowOnProductPage", - title: "@Loc["Vendor.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Fields.ShowOnProductPage"]", headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(ShowOnProductPage) {# #} else {# #} #' @@ -131,7 +134,7 @@ command: [ { name: "destroy", - text: "@Loc["Vendor.Common.Delete"]" + text: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Delete"]" } ] }] @@ -154,6 +157,6 @@ else {
- @Loc["Vendor.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"]
} \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 0000000000..91c03fd277 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 0000000000..b7958f1bee --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml deleted file mode 100644 index 21805ad8f6..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ /dev/null @@ -1,159 +0,0 @@ -@model ProductModel - -@if (!string.IsNullOrEmpty(Model.Id)) -{ - - - - - -} -else -{ -
- @Loc["Admin.Catalog.Products.SpecificationAttributes.SaveBeforeEdit"] -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 0000000000..f612fa5a4b --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 0000000000..7669a0c526 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + From 64638dd6bf837e652cda47e75233c9db3d9228c4 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:33:47 +0200 Subject: [PATCH 119/147] Migrate CreateOrUpdateProductAttributeValue.cshtml to AdminShared (ARCH-001 Phase 2) --- ...CreateOrUpdateProductAttributeValue.cshtml | 13 +- .../WidgetZone.AttributeValueButtons.cshtml | 1 + ...CreateOrUpdateProductAttributeValue.cshtml | 223 ------------------ ...CreateOrUpdateProductAttributeValue.cshtml | 223 ------------------ .../WidgetZone.AttributeValueButtons.cshtml | 1 + 5 files changed, 10 insertions(+), 451 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml (94%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml similarity index 94% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml index 4bca2da0f2..a30ce9d091 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml @@ -1,5 +1,8 @@ @using Microsoft.AspNetCore.Mvc.Razor @model ProductModel.ProductAttributeValueModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +}
@@ -76,8 +79,8 @@ @Model.AssociatedProductName - model.AssociatedProductId), productNameInput = "associate-product-name", area = Constants.AreaAdmin }))" class="k-button"> - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"] + model.AssociatedProductId), productNameInput = "associate-product-name", area = area }))" class="k-button"> + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]
@@ -185,7 +188,7 @@

@@ -213,9 +216,9 @@
- +
diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 0000000000..505ed8ce8f --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml deleted file mode 100644 index 6b635414ff..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductAttributeValueModel - -
- - - - - - - - -@{ - Func - template = @
-
- -
- - -
-
- -
; -} - - -
- - - -
-
- -
- - -
-
-
-
- -
- @if (Model.DisplayColorSquaresRgb) - { -
- -
- -
- - -
-
- } - @if (Model.DisplayImageSquaresPicture) - { -
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.ProductId; - } - -
- - -
-
- } -
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
    -
  • -

    - checked="checked" - }> -

    -

    - -

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

    - checked="checked" - }> -

    -

    - -

    -
  • - } -
- -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml deleted file mode 100644 index 72fc8f6e5d..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ /dev/null @@ -1,223 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductAttributeValueModel - -
- - - - - - - - -@{ - Func - template = @
-
- -
- - -
-
- -
; -} - - -
- - - -
-
- -
- - -
-
-
-
- -
- @if (Model.DisplayColorSquaresRgb) - { -
- -
- -
- - -
-
- } - @if (Model.DisplayImageSquaresPicture) - { -
- @{ - ViewData["Reference"] = "Product"; - ViewData["ObjectId"] = Model.ProductId; - } - -
- - -
-
- } -
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
    -
  • -

    - checked="checked" - }> -

    -

    - -

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

    - checked="checked" - }> -

    -

    - -

    -
  • - } -
- -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 0000000000..4d75e5543d --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + From 1eb78417d82a57b815ad09d73e4e3f142d5c701b Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:35:26 +0200 Subject: [PATCH 120/147] Migrate CreateOrUpdateTierPrice.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Partials/CreateOrUpdateTierPrice.cshtml | 39 ++++++----- .../WidgetZone.TierPrice.Bottom.cshtml | 2 + .../WidgetZone.TierPrice.Buttons.cshtml | 1 + .../Partials/WidgetZone.TierPrice.Top.cshtml | 2 + .../Partials/CreateOrUpdateTierPrice.cshtml | 69 ------------------- .../Partials/CreateOrUpdateTierPrice.cshtml | 55 --------------- .../WidgetZone.TierPrice.Bottom.cshtml | 2 + .../WidgetZone.TierPrice.Buttons.cshtml | 1 + .../Partials/WidgetZone.TierPrice.Top.cshtml | 2 + 9 files changed, 31 insertions(+), 142 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml (64%) create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml create mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml similarity index 64% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml index 628574efb9..b46b3040cd 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml @@ -1,11 +1,11 @@ -@model ProductModel.TierPriceModel +@model ProductModel.TierPriceModel
- +
@@ -28,20 +28,23 @@
-
- -
- - + @if (Scope.ResourceKeyPrefix != "Vendor") + { +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
+ }
@@ -59,11 +62,11 @@
- +
- -
\ No newline at end of file + +
diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 0000000000..10fc2245fe --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 0000000000..a5544db332 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 0000000000..559aa4d738 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml deleted file mode 100644 index 628574efb9..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ /dev/null @@ -1,69 +0,0 @@ -@model ProductModel.TierPriceModel - -
- - - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml deleted file mode 100644 index 3503bb67f2..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdateTierPrice.cshtml +++ /dev/null @@ -1,55 +0,0 @@ -@model ProductModel.TierPriceModel - -
- - - -
- -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- - -
-
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 0000000000..c853bd4c11 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 0000000000..f79cb9d5a7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 0000000000..01d166d137 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + From 2e73136c25e01c41486000ed5b153d0d8936bab8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:36:55 +0200 Subject: [PATCH 121/147] Migrate ProductAttributes.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/Partials/ProductAttributes.cshtml | 0 .../Product/Partials/ProductAttributes.cshtml | 96 ------------------- .../Product/Partials/ProductAttributes.cshtml | 96 ------------------- 3 files changed, 192 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/Partials/ProductAttributes.cshtml (100%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/ProductAttributes.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/ProductAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/Partials/ProductAttributes.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml deleted file mode 100644 index 09b543f616..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/ProductAttributes.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model IList -@if (Model.Count > 0) -{ -
- @foreach (var attribute in Model) - { - var controlId = $"attributes[{attribute.Id}]"; - var textPrompt = !string.IsNullOrEmpty(attribute.TextPrompt) ? attribute.TextPrompt : attribute.Name; -
-
- @if (attribute.IsRequired) - { - * - } - -
-
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.Checkboxes: - case AttributeControlType.ReadonlyCheckboxes: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.TextBox: - { - - } - break; - case AttributeControlType.MultilineTextbox: - { - - } - break; - case AttributeControlType.Datepicker: - { - - } - break; - case AttributeControlType.FileUpload: - { - - } - break; - } -
-
- } -
-} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml deleted file mode 100644 index 09b543f616..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/ProductAttributes.cshtml +++ /dev/null @@ -1,96 +0,0 @@ -@model IList -@if (Model.Count > 0) -{ -
- @foreach (var attribute in Model) - { - var controlId = $"attributes[{attribute.Id}]"; - var textPrompt = !string.IsNullOrEmpty(attribute.TextPrompt) ? attribute.TextPrompt : attribute.Name; -
-
- @if (attribute.IsRequired) - { - * - } - -
-
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.Checkboxes: - case AttributeControlType.ReadonlyCheckboxes: - { - foreach (var attributeValue in attribute.Values) - { -
- -
- } - } - break; - case AttributeControlType.TextBox: - { - - } - break; - case AttributeControlType.MultilineTextbox: - { - - } - break; - case AttributeControlType.Datepicker: - { - - } - break; - case AttributeControlType.FileUpload: - { - - } - break; - } -
-
- } -
-} \ No newline at end of file From 8d55f6690fbdb3f2ca458fbe30861ae777abcc50 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:39:02 +0200 Subject: [PATCH 122/147] Migrate ProductAttributeConditionPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../ProductAttributeConditionPopup.cshtml | 13 +- .../ProductAttributeConditionPopup.cshtml | 205 ------------------ .../ProductAttributeConditionPopup.cshtml | 200 ----------------- 3 files changed, 8 insertions(+), 410 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductAttributeConditionPopup.cshtml (95%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeConditionPopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeConditionPopup.cshtml index f9012f5a4f..65b9ba8bcb 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeConditionPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeConditionPopup.cshtml @@ -1,9 +1,12 @@ @model ProductAttributeConditionModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]; } -
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition.Description"]
@@ -145,7 +148,7 @@
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml deleted file mode 100644 index 52e99442d1..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeConditionPopup.cshtml +++ /dev/null @@ -1,205 +0,0 @@ -@model ProductAttributeConditionModel -@{ - Layout = ""; - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"]; -} - - - -
- - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition"] -
-
-
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] -
-
-
-
-
- -
- - -
-
-
- @if (Model.ProductAttributes.Count > 0) - { - var attributesList = new List(); - foreach (var attribute in Model.ProductAttributes) - { - attributesList.Add(new SelectListItem { Text = attribute.Name, Value = attribute.Id }); - } - -
-
-
- -
- - -
-
-
- -
- @foreach (var attribute in Model.ProductAttributes) - { - var controlId = $"attributes[{attribute.Id}]"; -
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.Checkboxes: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - break; - } -
- } -
-
-
-
- } - else - { -
No attribute exists that could be used as condition
- } -
-
-
-
- -
-
-
- -
-
-
-
-
-
- - - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml deleted file mode 100644 index 2b02275dd9..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeConditionPopup.cshtml +++ /dev/null @@ -1,200 +0,0 @@ -@model ProductAttributeConditionModel -@{ - Layout = ""; - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition"]; -} -
- - -
- - -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition"] -
-
-
- @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Condition.Description"] -
-
-
-
-
- -
- - -
-
-
- @if (Model.ProductAttributes.Count > 0) - { - var attributesList = Model.ProductAttributes.Select(attribute => new SelectListItem { Text = attribute.Name, Value = attribute.Id }).ToList(); -
-
-
- -
- - -
-
-
- -
- @foreach (var attribute in Model.ProductAttributes) - { - var controlId = $"attributes[{attribute.Id}]"; -
- @switch (attribute.AttributeControlType) - { - case AttributeControlType.DropdownList: - { - - } - break; - case AttributeControlType.RadioList: - case AttributeControlType.ColorSquares: - case AttributeControlType.ImageSquares: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.Checkboxes: - { -
    - @foreach (var attributeValue in attribute.Values) - { -
  • - - -
  • - } -
- } - break; - case AttributeControlType.ReadonlyCheckboxes: - case AttributeControlType.TextBox: - case AttributeControlType.MultilineTextbox: - case AttributeControlType.Datepicker: - case AttributeControlType.FileUpload: - default: - break; - } -
- } -
-
-
-
- } - else - { -
No attribute exists that could be used as condition
- } -
-
-
-
- -
-
-
- -
-
-
-
-
-
- - - -
\ No newline at end of file From fcec3a16bc81c7b74cc3424788b25a1a6ede1ae9 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:39:06 +0200 Subject: [PATCH 123/147] Migrate ProductAttributeMappingPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../ProductAttributeMappingPopup.cshtml | 13 +- .../ProductAttributeMappingPopup.cshtml | 139 ------------------ .../ProductAttributeMappingPopup.cshtml | 139 ------------------ 3 files changed, 8 insertions(+), 283 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductAttributeMappingPopup.cshtml (91%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeMappingPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeMappingPopup.cshtml index a08a7d827d..eb488bca73 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeMappingPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeMappingPopup.cshtml @@ -1,10 +1,13 @@ @model ProductModel.ProductAttributeMappingModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Details"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Details"]; } -
@if (string.IsNullOrEmpty(Model.Id)) { - @Loc["Admin.Catalog.Products.ProductAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] } else { - @Loc["Admin.Catalog.Products.ProductAttributes.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Edit"] }
@@ -95,7 +98,7 @@
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml deleted file mode 100644 index d64dad5355..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeMappingPopup.cshtml +++ /dev/null @@ -1,139 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Details"]; -} - - - -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Admin.Catalog.Products.ProductAttributes.AddNew"] - } - else - { - @Loc["Admin.Catalog.Products.ProductAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml deleted file mode 100644 index e6b7d0f7ff..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeMappingPopup.cshtml +++ /dev/null @@ -1,139 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Details"]; -} -
- - -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Vendor.Catalog.Products.ProductAttributes.AddNew"] - } - else - { - @Loc["Vendor.Catalog.Products.ProductAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- -
\ No newline at end of file From 7df47dde1fcda3415b6e2e6b2305cfccd1d0c6ea Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:39:11 +0200 Subject: [PATCH 124/147] Migrate ProductAttributeValidationRulesPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- ...roductAttributeValidationRulesPopup.cshtml | 11 +- ...roductAttributeValidationRulesPopup.cshtml | 111 ------------------ ...roductAttributeValidationRulesPopup.cshtml | 111 ------------------ 3 files changed, 7 insertions(+), 226 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductAttributeValidationRulesPopup.cshtml (91%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValidationRulesPopup.cshtml similarity index 91% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValidationRulesPopup.cshtml index 46e2c184b6..6c5cdf3c1e 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValidationRulesPopup.cshtml @@ -1,12 +1,15 @@ @model ProductModel.ProductAttributeMappingModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; var attributeControlType = Model.AttributeControlTypeId; } -
@@ -17,7 +20,7 @@
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]
@@ -62,7 +65,7 @@
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml deleted file mode 100644 index b2a17d0c74..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ /dev/null @@ -1,111 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; - - var attributeControlType = Model.AttributeControlTypeId; -} - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
- @if (!Model.ValidationRulesAllowed) - { -
This attribute type cannot have validation rules
- } - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml deleted file mode 100644 index de5e48cf60..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValidationRulesPopup.cshtml +++ /dev/null @@ -1,111 +0,0 @@ -@model ProductModel.ProductAttributeMappingModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules"]; - - var attributeControlType = Model.AttributeControlTypeId; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.ValidationRules"] -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
- @if (!Model.ValidationRulesAllowed) - { -
This attribute type cannot have validation rules
- } - -
\ No newline at end of file From 0d0c5be0fd0c7118b67e61f60cb68d85b9c19796 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:41:30 +0200 Subject: [PATCH 125/147] Migrate ProductAttributeValueCreatePopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../ProductAttributeValueCreatePopup.cshtml | 9 ++- .../ProductAttributeValueCreatePopup.cshtml | 59 ------------------- .../ProductAttributeValueCreatePopup.cshtml | 59 ------------------- 3 files changed, 6 insertions(+), 121 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductAttributeValueCreatePopup.cshtml (83%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueCreatePopup.cshtml similarity index 83% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueCreatePopup.cshtml index 24a392b4f0..a96a02328c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueCreatePopup.cshtml @@ -1,10 +1,13 @@ @model ProductModel.ProductAttributeValueModel +@{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); +} @{ Layout = ""; //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; } -
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml deleted file mode 100644 index 73afd065a8..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ /dev/null @@ -1,59 +0,0 @@ -@model ProductModel.ProductAttributeValueModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; -} - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] -
-
-
- -
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml deleted file mode 100644 index 9e3cff0d9f..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueCreatePopup.cshtml +++ /dev/null @@ -1,59 +0,0 @@ -@model ProductModel.ProductAttributeValueModel -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"] -
-
-
- -
-
-
-
- -
\ No newline at end of file From bbb5db0bbaf37936187f71dc742b5d00d41b19bb Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:41:34 +0200 Subject: [PATCH 126/147] Migrate ProductAttributeValueEditPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../ProductAttributeValueEditPopup.cshtml | 7 ++- .../ProductAttributeValueEditPopup.cshtml | 60 ------------------- .../ProductAttributeValueEditPopup.cshtml | 60 ------------------- 3 files changed, 4 insertions(+), 123 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductAttributeValueEditPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueEditPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueEditPopup.cshtml index 1875843774..7bc48dd2c6 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductAttributeValueEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductAttributeValueEditPopup.cshtml @@ -1,10 +1,11 @@ @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; } @model ProductModel.ProductAttributeValueModel -
- @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml deleted file mode 100644 index c5ad07ff7d..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductAttributeValueEditPopup.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; -} -@model ProductModel.ProductAttributeValueModel - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] -
-
-
- -
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml deleted file mode 100644 index ce676d3c6b..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductAttributeValueEditPopup.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"]; -} -@model ProductModel.ProductAttributeValueModel -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.ProductAttributes.Attributes.Values.EditValueDetails"] -
-
-
- -
-
-
-
- -
\ No newline at end of file From e5713dd99f4b015a4b283b611d76ac773d858f54 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:41:38 +0200 Subject: [PATCH 127/147] Migrate ProductPicturePopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/ProductPicturePopup.cshtml | 7 +- .../Views/Product/ProductPicturePopup.cshtml | 152 ------------------ .../Views/Product/ProductPicturePopup.cshtml | 152 ------------------ 3 files changed, 4 insertions(+), 307 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductPicturePopup.cshtml (95%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductPicturePopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductPicturePopup.cshtml index 8f4de15f95..6ce8f8fa20 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductPicturePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductPicturePopup.cshtml @@ -2,8 +2,9 @@ @model ProductModel.ProductPictureModel @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
- @Loc["Admin.Catalog.Products.Pictures.Details"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Pictures.Details"]
@@ -108,7 +109,7 @@
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml deleted file mode 100644 index e2d9be7d16..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductPicturePopup.cshtml +++ /dev/null @@ -1,152 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductPictureModel -@{ - Layout = ""; -} - - - - - @{ - Func template = @
-
- -
- - -
-
-
- -
- - -
-
- -
; - } - -
- - - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.Pictures.Details"] -
-
-
-
-
-
- -
- - - -
-
- -
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml deleted file mode 100644 index 7b210cbc27..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductPicturePopup.cshtml +++ /dev/null @@ -1,152 +0,0 @@ -@using Microsoft.AspNetCore.Mvc.Razor -@model ProductModel.ProductPictureModel -@{ - Layout = ""; -} -
- - - - @{ - Func template = @
-
- -
- - -
-
-
- -
- - -
-
- -
; - } - -
- - - - -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.Pictures.Details"] -
-
-
-
-
-
- -
- - - -
-
- -
-
- -
- - -
-
-
- -
- - -
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - \ No newline at end of file From 79d1df68a117b6e6eaef3422331f74af7ea6cc53 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:41:43 +0200 Subject: [PATCH 128/147] Migrate ProductSpecAttrPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/ProductSpecAttrPopup.cshtml | 11 +- .../Views/Product/ProductSpecAttrPopup.cshtml | 201 ------------------ .../Views/Product/ProductSpecAttrPopup.cshtml | 201 ------------------ 3 files changed, 6 insertions(+), 407 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/ProductSpecAttrPopup.cshtml (95%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/ProductSpecAttrPopup.cshtml similarity index 95% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/ProductSpecAttrPopup.cshtml index b9a3c24ffe..2da5182845 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/ProductSpecAttrPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/ProductSpecAttrPopup.cshtml @@ -1,8 +1,9 @@ @model ProductModel.AddProductSpecificationAttributeModel @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); } -
@if (string.IsNullOrEmpty(Model.Id)) { - @Loc["Admin.Catalog.Products.SpecificationAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddNew"] } else { - @Loc["Admin.Catalog.Products.SpecificationAttributes.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.Edit"] }
@@ -96,7 +97,7 @@
@@ -143,7 +144,7 @@ $.ajax({ cache: false, type: "GET", - url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = Constants.AreaAdmin }))", + url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = area }))", data: { "attributeId": selectedAttributeId }, success: function (data) { var ddlSpecOptions = $("#@Html.IdFor(model => model.SpecificationAttributeOptionId)"); diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml deleted file mode 100644 index 3d27fb777f..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/ProductSpecAttrPopup.cshtml +++ /dev/null @@ -1,201 +0,0 @@ -@model ProductModel.AddProductSpecificationAttributeModel -@{ - Layout = ""; -} - - -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.AddNew"] - } - else - { - @Loc["Admin.Catalog.Products.SpecificationAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml deleted file mode 100644 index a239232823..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/ProductSpecAttrPopup.cshtml +++ /dev/null @@ -1,201 +0,0 @@ -@model ProductModel.AddProductSpecificationAttributeModel -@{ - Layout = ""; -} -
- -
- - - -
-
-
-
-
- - @if (string.IsNullOrEmpty(Model.Id)) - { - @Loc["Vendor.Catalog.Products.SpecificationAttributes.AddNew"] - } - else - { - @Loc["Vendor.Catalog.Products.SpecificationAttributes.Edit"] - } -
-
-
-
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
- -
-
-
-
-
-
-
-
- - -
\ No newline at end of file From 57048e2041608f77dbcc018f92473d0467b7ec0c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:45:27 +0200 Subject: [PATCH 129/147] Migrate RecommendedProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/RecommendedProductAddPopup.cshtml | 55 +++-- .../Product/RecommendedProductAddPopup.cshtml | 209 ------------------ .../Product/RecommendedProductAddPopup.cshtml | 209 ------------------ 3 files changed, 31 insertions(+), 442 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/RecommendedProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/RecommendedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/RecommendedProductAddPopup.cshtml index 1a55ffa37a..5171568156 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RecommendedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/RecommendedProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddRecommendedProductModel +@model ProductModel.AddRecommendedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Recommended.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]; } -
@@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.Recommended.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]
@@ -33,13 +34,13 @@
- +
@@ -64,18 +65,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -128,8 +132,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -144,7 +151,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RecommendedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RecommendedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -176,7 +183,7 @@ scrollable: false, columns: [{ field: "Id", - title: "@Loc["Admin.Common.Check"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Common.Check"]", headerTemplate: "", headerAttributes: { style: "text-align:center" }, template: "", @@ -184,10 +191,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -220,4 +227,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml deleted file mode 100644 index 8cb2f5a156..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RecommendedProductAddPopup.cshtml +++ /dev/null @@ -1,209 +0,0 @@ -@model ProductModel.AddRecommendedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.Recommended.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml deleted file mode 100644 index e84e7bfde6..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RecommendedProductAddPopup.cshtml +++ /dev/null @@ -1,209 +0,0 @@ -@model ProductModel.AddRecommendedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.Recommended.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file From 78efefedc816577621993e07fed853c5a5a9f889 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:45:32 +0200 Subject: [PATCH 130/147] Migrate RelatedProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/RelatedProductAddPopup.cshtml | 59 ++--- .../Product/RelatedProductAddPopup.cshtml | 212 ------------------ .../Product/RelatedProductAddPopup.cshtml | 212 ------------------ 3 files changed, 33 insertions(+), 450 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/RelatedProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/RelatedProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/RelatedProductAddPopup.cshtml index 27144de2d2..78e26217d1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/RelatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/RelatedProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddRelatedProductModel +@model ProductModel.AddRelatedProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.RelatedProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]; } -
@@ -17,7 +18,7 @@
- @Loc["Admin.Catalog.Products.RelatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]
@@ -35,13 +36,13 @@
- +
@@ -66,18 +67,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -129,8 +133,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -142,12 +149,12 @@ var numChkBoxesChecked = $('#products-grid input[type=checkbox][id!=mastercheckbox]:checked').length; $('#mastercheckbox').prop('checked', numChkBoxes == numChkBoxesChecked && numChkBoxes > 0); } - + $("#products-grid").kendoGrid({ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("RelatedProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("RelatedProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -186,17 +193,17 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, template: '# if(Published) {# #} else {# #} #' }] }); - + $('#search-products').click(function () { var grid = $('#products-grid').data('kendoGrid'); grid.dataSource.page(1); //new search. Set page size to 1 @@ -221,6 +228,6 @@ $('#products-grid').on('change', 'input[type=checkbox][id!=mastercheckbox]', function(e) { updateMasterCheckbox(); }); - + - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml deleted file mode 100644 index 0604e40e48..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/RelatedProductAddPopup.cshtml +++ /dev/null @@ -1,212 +0,0 @@ -@model ProductModel.AddRelatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.RelatedProducts.AddNew"]; -} -
- - -
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml deleted file mode 100644 index d5d1a39c6b..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/RelatedProductAddPopup.cshtml +++ /dev/null @@ -1,212 +0,0 @@ -@model ProductModel.AddRelatedProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.RelatedProducts.AddNew"]; -} -
- - -
-
- -
-
- -
\ No newline at end of file From 1d43f09bb5c5a0f6098d8c6b55317b6750de084a Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:45:36 +0200 Subject: [PATCH 131/147] Migrate SimilarProductAddPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Product/SimilarProductAddPopup.cshtml | 53 +++-- .../Product/SimilarProductAddPopup.cshtml | 210 ------------------ .../Product/SimilarProductAddPopup.cshtml | 210 ------------------ 3 files changed, 30 insertions(+), 443 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/SimilarProductAddPopup.cshtml (82%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/SimilarProductAddPopup.cshtml similarity index 82% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/SimilarProductAddPopup.cshtml index d26e5564ab..8ae04c9dad 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/SimilarProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/SimilarProductAddPopup.cshtml @@ -1,11 +1,12 @@ -@model ProductModel.AddSimilarProductModel +@model ProductModel.AddSimilarProductModel @inject AdminAreaSettings adminAreaSettings @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.SimilarProducts.AddNew"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]; } -
@@ -15,7 +16,7 @@
- @Loc["Admin.Catalog.Products.SimilarProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]
@@ -33,13 +34,13 @@
- +
@@ -64,18 +65,21 @@
-
- -
- + @if (area == "Admin") + { +
+ +
+ +
-
-
- -
- +
+ +
+ +
-
+ }
@@ -128,8 +132,11 @@ SearchCategoryId: $('#SearchCategoryId').val(), SearchBrandId: $('#SearchBrandId').val(), SearchCollectionId: $('#SearchCollectionId').val(), - SearchStoreId: $('#SearchStoreId').val(), - SearchVendorId: $('#SearchVendorId').val(), + @if (area == "Admin") + { + SearchStoreId: $('#SearchStoreId').val(), + SearchVendorId: $('#SearchVendorId').val(), + } SearchProductTypeId: $('#SearchProductTypeId').val() }; addAntiForgeryToken(data); @@ -146,7 +153,7 @@ dataSource: { transport: { read: { - url: "@Html.Raw(Url.Action("SimilarProductAddPopupList", "Product", new { area = Constants.AreaAdmin }))", + url: "@Html.Raw(Url.Action("SimilarProductAddPopupList", "Product", new { area = area }))", type: "POST", dataType: "json", data: additionalData @@ -185,10 +192,10 @@ width: 50 }, { field: "Name", - title: "@Loc["Admin.Catalog.Products.Fields.Name"]" + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Name"]" }, { field: "Published", - title: "@Loc["Admin.Catalog.Products.Fields.Published"]", + title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Fields.Published"]", width: 100, headerAttributes: { style: "text-align:center" }, attributes: { style: "text-align:center" }, @@ -221,4 +228,4 @@ updateMasterCheckbox(); }); - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml deleted file mode 100644 index b58a4ff3ce..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/SimilarProductAddPopup.cshtml +++ /dev/null @@ -1,210 +0,0 @@ -@model ProductModel.AddSimilarProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.SimilarProducts.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml deleted file mode 100644 index b0a8f278b4..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/SimilarProductAddPopup.cshtml +++ /dev/null @@ -1,210 +0,0 @@ -@model ProductModel.AddSimilarProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - Layout = ""; - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.SimilarProducts.AddNew"]; -} -
-
-
- -
-
- -
\ No newline at end of file From e64ad0cfa08a8ac339cefdc1df7149a1b62ef80c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 19:45:41 +0200 Subject: [PATCH 132/147] Migrate TierPriceEditPopup.cshtml to AdminShared (ARCH-001 Phase 2) --- .../Views/Product/TierPriceEditPopup.cshtml | 7 ++- .../Views/Product/TierPriceEditPopup.cshtml | 61 ------------------- .../Views/Product/TierPriceEditPopup.cshtml | 61 ------------------- 3 files changed, 4 insertions(+), 125 deletions(-) rename src/Web/{Grand.Web.Admin/Areas/Admin => Grand.Web.AdminShared}/Views/Product/TierPriceEditPopup.cshtml (84%) delete mode 100644 src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml delete mode 100644 src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/TierPriceEditPopup.cshtml similarity index 84% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/Product/TierPriceEditPopup.cshtml index 6cfd03c6f3..dcd4d740ee 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/TierPriceEditPopup.cshtml @@ -2,12 +2,13 @@ @{ Layout = ""; + var area = ViewContext.RouteData.Values["area"]?.ToString(); //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.Edit"]; + ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]; } -
@@ -18,7 +19,7 @@
- @Loc["Admin.Catalog.Products.TierPrices.Edit"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml deleted file mode 100644 index c27cac08c5..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceEditPopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Admin.Catalog.Products.TierPrices.Edit"]; -} - - - -
-
-
-
-
- - @Loc["Admin.Catalog.Products.TierPrices.Edit"] -
-
-
- -
-
-
-
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml deleted file mode 100644 index fc5ae7807f..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceEditPopup.cshtml +++ /dev/null @@ -1,61 +0,0 @@ -@model ProductModel.TierPriceModel - -@{ - Layout = ""; - - //page title - ViewBag.Title = Loc["Vendor.Catalog.Products.TierPrices.Edit"]; -} - -
- -
-
-
-
-
- - @Loc["Vendor.Catalog.Products.TierPrices.Edit"] -
-
-
- -
-
-
-
- -
\ No newline at end of file From 2b5b9d567a9e14ac3ee9a1bee2c8601df674375c Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Tue, 18 Aug 2026 20:21:44 +0200 Subject: [PATCH 133/147] Fix dead admin widget zones in consolidated Product views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` binds to AdminWidgetViewComponent in Grand.Web.Admin. Razor binds tag helpers at compile time from the compiling project's `@addTagHelper` set; Grand.Web.AdminShared only adds Grand.Web.Common, so all 44 widget-zone calls in the moved Product views compiled to literal `` markup — an Admin-host regression, silent at build and at runtime. Move the 40 `WidgetZone.*.cshtml` defaults into Grand.Web.Admin/Areas/Admin/Views/Product/Partials/, where the tag helper is registered and host-override precedence finds them first. Leave empty commented placeholders in AdminShared so Store falls through to a deliberate no-op instead of stray unbound markup (its pre-existing behaviour, now explicit). Extract the 4 inline calls in CreateOrUpdate.Discounts/.Documents into WidgetZone.{Discounts,Documents}.{Top,Bottom} partial pairs following the same pattern; those two parents stay in AdminShared since Store needs them. Also closes M7: their hardcoded `Loc["Admin.…"]` keys now use `Scope.ResourceKeyPrefix`. Verified: literal ` 0; AdminWidgetViewComponent refs in Grand.Web.Admin.dll 335 -> 379. Co-Authored-By: Claude Sonnet 5 --- .../Partials/WidgetZone.Additional.Bottom.cshtml | 1 + .../Partials/WidgetZone.Additional.Top.cshtml | 1 + .../WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../WidgetZone.AssociatedProducts.Top.cshtml | 1 + .../Partials/WidgetZone.AttributeDetailsTabs.cshtml | 1 + .../Partials/WidgetZone.AttributeValueButtons.cshtml | 1 + .../Product/Partials/WidgetZone.Bids.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Bids.Top.cshtml | 1 + .../Partials/WidgetZone.BulkEditButtons.cshtml | 1 + .../Partials/WidgetZone.BundleProducts.Bottom.cshtml | 1 + .../Partials/WidgetZone.BundleProducts.Top.cshtml | 1 + .../Partials/WidgetZone.Calendar.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Calendar.Top.cshtml | 1 + .../Partials/WidgetZone.Categories.Bottom.cshtml | 1 + .../Partials/WidgetZone.Categories.Top.cshtml | 1 + .../Partials/WidgetZone.Collections.Bottom.cshtml | 1 + .../Partials/WidgetZone.Collections.Top.cshtml | 1 + .../Partials/WidgetZone.CrossSells.Bottom.cshtml | 1 + .../Partials/WidgetZone.CrossSells.Top.cshtml | 1 + .../Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Partials/WidgetZone.Discounts.Bottom.cshtml | 2 ++ .../Product/Partials/WidgetZone.Discounts.Top.cshtml | 2 ++ .../Partials/WidgetZone.Documents.Bottom.cshtml | 2 ++ .../Product/Partials/WidgetZone.Documents.Top.cshtml | 2 ++ .../Partials/WidgetZone.Inventory.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Inventory.Top.cshtml | 1 + .../Partials/WidgetZone.Pictures.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Pictures.Top.cshtml | 1 + .../WidgetZone.ProductAttributes.Bottom.cshtml | 1 + ...Zone.ProductAttributes.Combinations.Bottom.cshtml | 1 + ...getZone.ProductAttributes.Combinations.Top.cshtml | 1 + .../Partials/WidgetZone.ProductAttributes.Top.cshtml | 1 + .../Partials/WidgetZone.Recommended.Bottom.cshtml | 1 + .../Partials/WidgetZone.Recommended.Top.cshtml | 1 + .../Partials/WidgetZone.Reviews.Bottom.cshtml | 2 ++ .../Product/Partials/WidgetZone.Reviews.Top.cshtml | 2 ++ .../Product/Partials/WidgetZone.SEO.Bottom.cshtml | 2 ++ .../Views/Product/Partials/WidgetZone.SEO.Top.cshtml | 2 ++ .../WidgetZone.SpecificationAttributes.Bottom.cshtml | 2 ++ .../WidgetZone.SpecificationAttributes.Top.cshtml | 2 ++ .../Views/Product/Partials/WidgetZone.Tabs.cshtml | 1 + .../Partials/WidgetZone.TierPrice.Bottom.cshtml | 2 ++ .../Partials/WidgetZone.TierPrice.Buttons.cshtml | 1 + .../Product/Partials/WidgetZone.TierPrice.Top.cshtml | 2 ++ .../Product/Partials/CreateOrUpdate.Discounts.cshtml | 6 +++--- .../Product/Partials/CreateOrUpdate.Documents.cshtml | 12 ++++++------ .../Partials/WidgetZone.Additional.Bottom.cshtml | 2 +- .../Partials/WidgetZone.Additional.Top.cshtml | 2 +- .../WidgetZone.AssociatedProducts.Bottom.cshtml | 2 +- .../WidgetZone.AssociatedProducts.Top.cshtml | 2 +- .../Partials/WidgetZone.AttributeDetailsTabs.cshtml | 2 +- .../Partials/WidgetZone.AttributeValueButtons.cshtml | 2 +- .../Product/Partials/WidgetZone.Bids.Bottom.cshtml | 2 +- .../Product/Partials/WidgetZone.Bids.Top.cshtml | 2 +- .../Partials/WidgetZone.BulkEditButtons.cshtml | 2 +- .../Partials/WidgetZone.BundleProducts.Bottom.cshtml | 2 +- .../Partials/WidgetZone.BundleProducts.Top.cshtml | 2 +- .../Partials/WidgetZone.Calendar.Bottom.cshtml | 2 +- .../Product/Partials/WidgetZone.Calendar.Top.cshtml | 2 +- .../Partials/WidgetZone.Categories.Bottom.cshtml | 2 +- .../Partials/WidgetZone.Categories.Top.cshtml | 2 +- .../Partials/WidgetZone.Collections.Bottom.cshtml | 2 +- .../Partials/WidgetZone.Collections.Top.cshtml | 2 +- .../Partials/WidgetZone.CrossSells.Bottom.cshtml | 2 +- .../Partials/WidgetZone.CrossSells.Top.cshtml | 2 +- .../Partials/WidgetZone.DetailsButtons.cshtml | 2 +- .../Partials/WidgetZone.Discounts.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Discounts.Top.cshtml | 1 + .../Partials/WidgetZone.Documents.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.Documents.Top.cshtml | 1 + .../Partials/WidgetZone.Inventory.Bottom.cshtml | 2 +- .../Product/Partials/WidgetZone.Inventory.Top.cshtml | 2 +- .../Partials/WidgetZone.Pictures.Bottom.cshtml | 2 +- .../Product/Partials/WidgetZone.Pictures.Top.cshtml | 2 +- .../WidgetZone.ProductAttributes.Bottom.cshtml | 2 +- ...Zone.ProductAttributes.Combinations.Bottom.cshtml | 2 +- ...getZone.ProductAttributes.Combinations.Top.cshtml | 2 +- .../Partials/WidgetZone.ProductAttributes.Top.cshtml | 2 +- .../Partials/WidgetZone.Recommended.Bottom.cshtml | 2 +- .../Partials/WidgetZone.Recommended.Top.cshtml | 2 +- .../Partials/WidgetZone.Reviews.Bottom.cshtml | 3 +-- .../Product/Partials/WidgetZone.Reviews.Top.cshtml | 3 +-- .../Product/Partials/WidgetZone.SEO.Bottom.cshtml | 3 +-- .../Views/Product/Partials/WidgetZone.SEO.Top.cshtml | 3 +-- .../WidgetZone.SpecificationAttributes.Bottom.cshtml | 3 +-- .../WidgetZone.SpecificationAttributes.Top.cshtml | 3 +-- .../Views/Product/Partials/WidgetZone.Tabs.cshtml | 2 +- .../Partials/WidgetZone.TierPrice.Bottom.cshtml | 3 +-- .../Partials/WidgetZone.TierPrice.Buttons.cshtml | 2 +- .../Product/Partials/WidgetZone.TierPrice.Top.cshtml | 3 +-- 90 files changed, 109 insertions(+), 57 deletions(-) create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone.Documents.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml new file mode 100644 index 0000000000..b2bea2c08d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml new file mode 100644 index 0000000000..d9af4164c2 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml new file mode 100644 index 0000000000..365803772c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml new file mode 100644 index 0000000000..41ac70608f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml new file mode 100644 index 0000000000..2890283570 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml new file mode 100644 index 0000000000..505ed8ce8f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml new file mode 100644 index 0000000000..5d2805eddb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml new file mode 100644 index 0000000000..30a6fd57cd --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml new file mode 100644 index 0000000000..4ba491b7e2 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BulkEditButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml new file mode 100644 index 0000000000..6393243e7e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml new file mode 100644 index 0000000000..cd795bb689 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml new file mode 100644 index 0000000000..a4ab3e0fd6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml new file mode 100644 index 0000000000..30ece34a2e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml new file mode 100644 index 0000000000..2372482976 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml new file mode 100644 index 0000000000..843f509a38 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml new file mode 100644 index 0000000000..2feef39d30 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml new file mode 100644 index 0000000000..ad2b99fdac --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml new file mode 100644 index 0000000000..293613eb81 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml new file mode 100644 index 0000000000..5347c99b88 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..2f59d184d9 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml new file mode 100644 index 0000000000..230607bc66 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml new file mode 100644 index 0000000000..aa5390666a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Discounts.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml new file mode 100644 index 0000000000..569744a25b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml new file mode 100644 index 0000000000..cefcddbd8f --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Documents.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml new file mode 100644 index 0000000000..94f1a73ec3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml new file mode 100644 index 0000000000..3b6542cd1c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml new file mode 100644 index 0000000000..b57690790e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml new file mode 100644 index 0000000000..04182faca6 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml new file mode 100644 index 0000000000..4725ad56fb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml new file mode 100644 index 0000000000..c0add9776e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml new file mode 100644 index 0000000000..b4bfd091fb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml new file mode 100644 index 0000000000..76a0a3507d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml new file mode 100644 index 0000000000..b32a3fa65a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml new file mode 100644 index 0000000000..1694947c39 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml new file mode 100644 index 0000000000..457da7c516 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml new file mode 100644 index 0000000000..9423f693eb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Reviews.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml new file mode 100644 index 0000000000..7932bdf55d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml new file mode 100644 index 0000000000..1f49d9a74c --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SEO.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml new file mode 100644 index 0000000000..91c03fd277 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml new file mode 100644 index 0000000000..b7958f1bee --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.SpecificationAttributes.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml new file mode 100644 index 0000000000..8d3c1910cd --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml new file mode 100644 index 0000000000..10fc2245fe --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml new file mode 100644 index 0000000000..a5544db332 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml @@ -0,0 +1 @@ + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml new file mode 100644 index 0000000000..559aa4d738 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml @@ -0,0 +1,2 @@ +@model ProductModel.TierPriceModel + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml index 8380a9dd4f..85fd8ff2a9 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Discounts.cshtml @@ -1,5 +1,5 @@ @model ProductModel - + @if (Model.AvailableDiscounts is { Count: > 0 }) {
@@ -19,7 +19,7 @@ else {
- @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"]) + @Html.Raw(Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Collections.Discounts.NoDiscounts"])
} - + diff --git a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml index 58cd8cc948..ba7c1afb0d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/Product/Partials/CreateOrUpdate.Documents.cshtml @@ -4,14 +4,14 @@ var area = ViewContext.RouteData.Values["area"]?.ToString(); }
- +
- +
- +
@@ -739,4 +739,4 @@ }
- \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 27d95349ab..f527364341 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -1,6 +1,6 @@ @model ProductModel @inject AdminAreaSettings adminAreaSettings - +
@@ -383,4 +383,4 @@ else @Html.Raw(Loc["Admin.Catalog.Collections.Discounts.NoDiscounts"])
} - \ No newline at end of file + \ No newline at end of file diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml index 3b94ef6c12..395ca8f154 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.PurchasedWithOrders.cshtml @@ -2,11 +2,11 @@ @inject AdminAreaSettings adminAreaSettings
- +
- +
- -``` - -Note `Layout = ""` (a popup with no chrome) — this file happens not to depend -on the new `_ViewStart.cshtml` from Step 2 at all, but every other Task 4 row -that omits `Layout` does, so `_ViewStart.cshtml` must exist before any row -that relies on it is migrated. It's created in this task so it's already in -place for all of Task 4. - -- [ ] **Step 5: Delete the three host copies** - -```bash -git rm src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/TierPriceCreatePopup.cshtml -git rm src/Web/Grand.Web.Store/Areas/Store/Views/Product/TierPriceCreatePopup.cshtml -git rm src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/TierPriceCreatePopup.cshtml -``` - -- [ ] **Step 6: Build all three hosts** - -Run: -``` -dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj -dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj -dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj -dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj -``` -Expected: all succeed. A missing `@inject` or bad `@using` in the new -`_ViewImports.cshtml`/the view itself shows up here as a Razor compile error -(this project does compile-time Razor validation as part of `dotnet build`, -not only at first request). - -- [ ] **Step 7: Manual smoke check** - -If a local Kestrel instance is available (per project memory -`reference_running_the_storefront`), open the Admin panel, edit a product, -open its Tier prices tab, click "Add new" — confirm the popup renders with -the correct title, submits, and closes. Repeat once for Store and once for -Vendor (each showing the resource key under their own prefix). If no local -instance is available, note this in the commit message body and rely on the -build success + Task 4/5's aggregate manual pass to catch it. - -- [ ] **Step 8: Commit** - -```bash -git add -A -git commit -m "Migrate TierPriceCreatePopup to AdminShared, pilot for view consolidation (ARCH-001 Phase 2)" -``` - ---- - -## Task 4: Migrate the remaining Product views - -Same per-row discipline as Phase 1's Task 8/10: one filename per row, each -read across every host that has it, classified, migrated (or left as a -host-specific override), verified with a build, and committed independently. -Follow Task 3's template exactly: same `_ViewImports.cshtml` (already in -place, don't recreate it), same "read all present variants → diff → resolve -`asp-area` literal via `ViewContext.RouteData.Values["area"]` and resource -prefix via `Scope.ResourceKeyPrefix` → write one file in -`Grand.Web.AdminShared/Views/Product/` (or its `Partials/` subfolder, matching -each file's current subfolder) → delete host copies → build → commit" cycle. - -**Files (per row):** -- Create: `src/Web/Grand.Web.AdminShared/Views/Product/.cshtml` (or - `Partials/.cshtml`) — unless the row's classification is "keep as - host override", in which case no AdminShared file is created. -- Delete: the file's copy in each host that currently has it (2 or 3 of - `src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/...`, - `src/Web/Grand.Web.Store/Areas/Store/Views/Product/...`, - `src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/...`) — unless kept as - an override, in which case only the non-kept hosts' copies (if any - duplicate the kept one byte-for-byte) are candidates for deletion; if in - doubt, keep all host copies that currently exist for an override row and - note the ambiguity in the commit message rather than guessing. - -**Known baseline (2026-08-17), `diffAS`/`diffAV` = number of changed diff -lines, Admin-vs-Store / Admin-vs-Vendor, `diff -U0` line count; `presence` = -which hosts currently have the file (Vendor is missing two files entirely). -Recompute for any file if the repo has drifted since — this table is a -starting hint, not a substitute for reading the actual current files:** - -| # | File | Presence | diffAS | diffAV | Starting hint | -|---|---|---|---|---|---| -| 1 | `AssociateProductToAttributeValuePopup.cshtml` | A,S,V | 18 | 34 | small-medium diff, likely area+prefix only — verify | -| 2 | `AssociatedProductAddPopup.cshtml` | A,S,V | 20 | 34 | same shape as #1 | -| 3 | `AttributeCombinationPopup.cshtml` | A,S,V | 10 | 131 | large Admin/Vendor diff — read closely, may have a real difference | -| 4 | `BulkEdit.cshtml` | A,S,V | 21 | 47 | check for the vendor-scoped bulk-edit grid difference (Phase 1 Task 10 note) | -| 5 | `BundleProductAddPopup.cshtml` | A,S,V | 20 | 30 | same shape as #1 | -| 6 | `Create.cshtml` | A,S,V | 2 | 14 | small — likely trivial unify (relies on the shared `_ViewStart.cshtml` for Layout) | -| 7 | `CrossSellProductAddPopup.cshtml` | A,S,V | 18 | 34 | same shape as #1 | -| 8 | `Edit.cshtml` | A,S,V | 4 | 26 | small-medium — check the Store `EditWarningCheck` hook (Phase 1) has a matching view-side warning banner | -| 9 | `List.cshtml` | A,S,V | 141 | 110 | **real functional difference** — Admin has a bulk export/import/delete panel and Store/Vendor filter panel drops `SearchStoreId`/`SearchVendorId`; likely candidate for host-specific override per host, not a single unified file | -| 10 | `Partials/CreateOrUpdate.Additional.cshtml` | A,S,V | 2 | 94 | Admin/Store trivial; Vendor differs a lot — read before assuming unifiable | -| 11 | `Partials/CreateOrUpdate.AssociatedProducts.cshtml` | A,S,V | 10 | 32 | medium | -| 12 | `Partials/CreateOrUpdate.Bids.cshtml` | A,S,V | 6 | 22 | small-medium | -| 13 | `Partials/CreateOrUpdate.BundleProducts.cshtml` | A,S,V | 10 | 32 | medium | -| 14 | `Partials/CreateOrUpdate.Calendar.cshtml` | A,S,V | 12 | 60 | medium-large | -| 15 | `Partials/CreateOrUpdate.Categories.cshtml` | A,S,V | 12 | 54 | medium-large | -| 16 | `Partials/CreateOrUpdate.Collections.cshtml` | A,S,V | 12 | 44 | medium | -| 17 | `Partials/CreateOrUpdate.CrossSells.cshtml` | A,S,V | 8 | 22 | small-medium | -| 18 | `Partials/CreateOrUpdate.Discounts.cshtml` | A,S only | 0 | n/a | **Admin/Store byte-identical, Vendor has no such tab at all** — unify Admin+Store into one AdminShared file; Vendor simply never requests it (Phase 1's Vendor tab set already excludes Discounts) | -| 19 | `Partials/CreateOrUpdate.Documents.cshtml` | A,S only | 8 | n/a | Admin/Store small diff, Vendor has no such tab — same treatment as #18 once Admin/Store diff is resolved | -| 20 | `Partials/CreateOrUpdate.Info.cshtml` | A,S,V | 26 | 160 | **large diff** — read very closely, likely has real per-host fields (e.g. vendor selector shown/hidden) | -| 21 | `Partials/CreateOrUpdate.Inventory.cshtml` | A,S,V | 0 | 20 | Admin/Store byte-identical; Vendor differs — likely unifiable with a `Scope`-driven conditional | -| 22 | `Partials/CreateOrUpdate.Pictures.cshtml` | A,S,V | 8 | 28 | small-medium | -| 23 | `Partials/CreateOrUpdate.Prices.cshtml` | A,S,V | 8 | 83 | medium-large, Vendor differs a lot | -| 24 | `Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml` | A,S,V | 12 | 40 | medium | -| 25 | `Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml` | A,S,V | 20 | 42 | medium | -| 26 | `Partials/CreateOrUpdate.ProductAttributes.cshtml` | A,S,V | 0 | 10 | Admin/Store byte-identical | -| 27 | `Partials/CreateOrUpdate.ProductPrices.cshtml` | A,S,V | 8 | 49 | medium | -| 28 | `Partials/CreateOrUpdate.PurchasedWithOrders.cshtml` | A,S,V | 6 | 52 | medium | -| 29 | `Partials/CreateOrUpdate.Recommended.cshtml` | A,S,V | 8 | 22 | small-medium | -| 30 | `Partials/CreateOrUpdate.RelatedProducts.cshtml` | A,S,V | 10 | 26 | small-medium | -| 31 | `Partials/CreateOrUpdate.Reviews.cshtml` | A,S,V | 6 | 20 | small-medium | -| 32 | `Partials/CreateOrUpdate.SEO.cshtml` | A,S,V | 0 | 4 | tiny — near-trivial | -| 33 | `Partials/CreateOrUpdate.SimilarProducts.cshtml` | A,S,V | 10 | 26 | small-medium | -| 34 | `Partials/CreateOrUpdate.SpecificationAttributes.cshtml` | A,S,V | 10 | 26 | small-medium | -| 35 | `Partials/CreateOrUpdate.cshtml` | A,S,V | 0 | 60 | Admin/Store byte-identical; Vendor differs (likely the tab list itself — fewer tabs for Vendor, e.g. no Discounts/Documents) — read closely, this is the tab-container partial | -| 36 | `Partials/CreateOrUpdateProductAttributeValue.cshtml` | A,S,V | 2 | 10 | small — likely trivial | -| 37 | `Partials/CreateOrUpdateTierPrice.cshtml` | A,S,V | 0 | 22 | Admin/Store byte-identical; Vendor differs | -| 38 | `Partials/ProductAttributes.cshtml` | A,S,V | 0 | 0 | **byte-identical across all three hosts already** — trivial unify, no conditionals needed | -| 39 | `ProductAttributeConditionPopup.cshtml` | A,S,V | 2 | 16 | small | -| 40 | `ProductAttributeMappingPopup.cshtml` | A,S,V | 2 | 10 | small — same shape as Task 3's pilot | -| 41 | `ProductAttributeValidationRulesPopup.cshtml` | A,S,V | 2 | 8 | small — same shape as Task 3's pilot | -| 42 | `ProductAttributeValueCreatePopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot, diff confirmed 2026-08-17 | -| 43 | `ProductAttributeValueEditPopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot | -| 44 | `ProductPicturePopup.cshtml` | A,S,V | 2 | 8 | small | -| 45 | `ProductSpecAttrPopup.cshtml` | A,S,V | 4 | 10 | small | -| 46 | `RecommendedProductAddPopup.cshtml` | A,S,V | 20 | 34 | same shape as #1 | -| 47 | `RelatedProductAddPopup.cshtml` | A,S,V | 18 | 32 | same shape as #1 | -| 48 | `RequiredProductAddPopup.cshtml` | A,S,V | 18 | 52 | medium | -| 49 | `SimilarProductAddPopup.cshtml` | A,S,V | 20 | 32 | same shape as #1 | -| 50 | `TierPriceEditPopup.cshtml` | A,S,V | 2 | 6 | small — same shape as Task 3's pilot | - -(`TierPriceCreatePopup.cshtml` is row 0, done in Task 3.) - -- [ ] **Step 1 (repeat per row): read, classify, migrate (or override), build, commit** - -For each row: -1. Read every present host's copy of the file in full. -2. Classify per spec section 4: - - Byte-identical or differs only by area literal / resource prefix already - covered by `Scope.ResourceKeyPrefix` → write one file in AdminShared, - following Task 3's pattern (`ViewContext.RouteData.Values["area"]` for - the area, `Scope.ResourceKeyPrefix` for resource keys), delete host - copies. - - Differs by a capability flag Phase 1 already introduced on the view - model (e.g. a bool controlling whether a field renders) → one file with - an `@if (Model.SomeFlag) { ... }`, delete host copies. - - Real functional difference → leave every existing host copy exactly - where it is; do not touch it, do not create an AdminShared file for it. - `RazorViewEngine`'s host-location-first ordering (Task 2) means these - continue rendering exactly as before with zero code change — this row - is only a documented "leave alone" decision, still worth its own commit - noting why, for the audit trail. -3. Build: `dotnet build src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj && dotnet build src/Web/Grand.Web.Admin/Grand.Web.Admin.csproj && dotnet build src/Web/Grand.Web.Store/Grand.Web.Store.csproj && dotnet build src/Web/Grand.Web.Vendor/Grand.Web.Vendor.csproj` -4. Commit: -```bash -git add -A -git commit -m "Migrate to AdminShared (ARCH-001 Phase 2)" -# or, for a "leave alone" row: -git commit -m "Keep as host-specific override, no unification (ARCH-001 Phase 2)" --allow-empty -``` - -- [ ] **Step 2: After all 50 rows are checked off, confirm no orphaned host copies remain for unified files** - -Run: -``` -find src/Web/Grand.Web.Admin/Areas/Admin/Views/Product src/Web/Grand.Web.Store/Areas/Store/Views/Product src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product -iname "*.cshtml" | sort -find src/Web/Grand.Web.AdminShared/Views/Product -iname "*.cshtml" | sort -``` -Cross-check against the row-by-row decisions recorded in commit messages — -every file that was unified should no longer exist under any host's own -`Views/Product/`, and every file kept as an override should exist in exactly -the hosts that had it originally (not fewer, not more). - ---- - -## Task 5: Full-solution verification - -**Files:** none — verification only. - -- [ ] **Step 1: Full solution build** - -Run: `dotnet build GrandNode.sln` -Expected: Build succeeded, 0 errors. - -- [ ] **Step 2: Full test run for the affected test projects** - -Run (individually, per project memory `project_test_suite_flaky_parallel` — -not a single solution-wide `dotnet test`): -``` -dotnet test src/Tests/Grand.Web.Common.Tests -dotnet test src/Tests/Grand.Web.Admin.Tests -dotnet test src/Tests/Grand.Web.Store.Tests -dotnet test src/Tests/Grand.Web.Vendor.Tests -``` -Expected: all PASS. - -- [ ] **Step 3: File-count sanity check against the ARCH-001 Phase 2 baseline** - -Run: -``` -find src/Web/Grand.Web.Admin/Areas/Admin/Views/Product -iname "*.cshtml" | wc -l -find src/Web/Grand.Web.Store/Areas/Store/Views/Product -iname "*.cshtml" | wc -l -find src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product -iname "*.cshtml" | wc -l -find src/Web/Grand.Web.AdminShared/Views/Product -iname "*.cshtml" | wc -l -``` -Expected: the three host counts have dropped from the Task 0 baseline -(51/51/49) by however many rows were unified in Task 4; `AdminShared/Views/ -Product` holds that many files (plus 1 for Task 3's pilot). The three host -counts plus the AdminShared count, accounting for files present in more than -one host before migration, should reconcile against Task 4's per-row log — -if they don't, some row was migrated inconsistently; find and fix it before -proceeding. - -- [ ] **Step 4: Manual smoke test** - -If a local Kestrel instance is available (per project memory -`reference_running_the_storefront`): log into each of the three admin panels -and open Product → List → Create → Edit → Save for one existing product per -host. Confirm: -- No `InvalidOperationException: The view '...' was not found` error for any - action. -- Each host's layout/chrome renders correctly (validates the `_ViewStart.cshtml` - fix from Task 3/spec section 3a). -- Host-specific content still appears only where expected (Admin's bulk - export/import/delete panel on `List.cshtml`; Vendor has no - Discounts/Documents tab). -- Popups (tier price, product attribute value, etc.) open, submit, and close - correctly on all three hosts. - -If no local instance is available, report this explicitly as unverified -rather than claiming the pass was done. - -- [ ] **Step 5: Update the ARCH-001 project memory** - -Edit the memory file `project_arch001_triple_admin_duplication.md` (outside -this repo, in the memory directory) to record Phase 2 complete: views -unified, host-specific overrides count, any views deliberately left -un-unified and why, and that the `Grand.Web.AdminShared` + `ViewLocationExpander` -pattern is now proven end-to-end (controller, service, and view layers) and -ready to reuse for the next entity. - -- [ ] **Step 6: Final commit** - -```bash -git add -A -git commit -m "ARCH-001 Phase 2 complete: Product views consolidated into AdminShared" -``` diff --git a/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md b/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md deleted file mode 100644 index 33373262d9..0000000000 --- a/docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md +++ /dev/null @@ -1,150 +0,0 @@ -# ARCH-001 — Product panel consolidation design - -Date: 2026-08-16 -Status: Approved, ready for implementation planning - -## Problem - -`Grand.Web.Admin`, `Grand.Web.Store`, and `Grand.Web.Vendor` each carry their own -copy of `ProductController` (2478 / 2625 / 2584 lines) and, for the view-model -layer, `Grand.Web.AdminShared` and `Grand.Web.Vendor` each carry their own -`ProductViewModelService` (2571 / 2381 lines, 1768 lines of diff). The copies -have drifted too far to merge mechanically. A bug fix or security patch in the -product editor currently requires three independent edits, and history shows -that requirement gets missed (commits #754, #765 fixed antiforgery handling in -some panels but not all). Full finding recorded in project memory -`project_arch001_triple_admin_duplication.md`. - -This spec covers **only the `Product` vertical** (`ProductController` + -`ProductViewModelService` + `Product` views) as the first, highest-value slice -of ARCH-001. It does not attempt to generalize to every entity yet, though the -core abstraction is named and shaped so Order/Category/Collection can adopt it -later without redesign. - -## Existing precedent - -`Grand.Web.AdminShared/Controllers/BaseLoginController.cs` already implements -this exact pattern: an abstract base controller in AdminShared, with three -21-line subclasses (`Grand.Web.Admin/Store/Vendor/Controllers/LoginController.cs`) -that add only `[Area(...)]` and pass constructor args through. This design -follows that precedent at Product's scale. - -Recent groundwork already in place (as of 2026-08-16): -- Characterization tests exist for all three `ProductController`s - (`src/Tests/Grand.Web.{Admin,Store,Vendor}.Tests/Controllers/ProductControllerTests.cs`) - and for both `ProductViewModelService`s (`Grand.Web.Admin.Tests` covers - AdminShared's, `Grand.Web.Vendor.Tests` covers Vendor's). -- #786 deduped Store's `ProductController` access checks onto a single - `CanAccessProduct` helper. -- #785 deduped Vendor's access checks similarly. -- #788 synced Vendor's `ProductViewModelService` to AdminShared's - primary-constructor style, reducing incidental diff noise before a merge. - -These give a safety net for a direct migration (no parallel-run / feature flag -needed — chosen deliberately over a flagged rollout given the test coverage -already in place). - -## Current access-scope patterns (what `IAdminDataScope` must replace) - -- **Admin**: no filtering — global access to all products. -- **Store** (`Grand.Web.Store/Controllers/ProductController.cs`): scattered - direct reads of `_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId`, - used both to filter lists/queries and as a default value written onto new/ - edited products (`model.StoreId`, `model.Stores`, list search filters). -- **Vendor** (`Grand.Web.Vendor/Controllers/ProductController.cs`): entity-level - checks via `_contextAccessor.WorkContext.HasAccessToProduct(product)`, - applied per-action rather than as a list filter. - -## Architecture - -A new abstraction in `Grand.Web.AdminShared`: - -```csharp -public interface IAdminDataScope -{ - Task HasAccess(TEntity entity); - IQueryable ApplyScope(IQueryable query); - string? DefaultStoreId { get; } // null for Admin/Vendor, StaffStoreId for Store -} -``` - -Three implementations, one per host, each registered in that host's own -`Startup` (matching how each host registers its own services today): - -- `GlobalAdminDataScope` (Admin) — `HasAccess` always true, `ApplyScope` - is a no-op, `DefaultStoreId` is `null`. -- `StoreAdminDataScope` (Store) — wraps `StaffStoreId` filtering/ - defaulting in one place instead of the current scattered call sites. -- `VendorAdminDataScope` (Vendor) — delegates to the existing - `IWorkContext.HasAccessToProduct` (or the generalized equivalent). - -`DefaultStoreId` exists specifically to make today's implicit per-host -default (Store always stamps `model.StoreId`; Admin never does) explicit and -testable instead of an artifact of the diff. - -The interface is typed generically (``) and named without a `Product` -suffix so Order/Category/Collection can implement it later, but this spec -only ships the `Product` instantiation and only what `ProductController` -actually needs — no speculative members beyond the three above. - -## Phase 1 — Controller and service consolidation - -- `Grand.Web.AdminShared/Controllers/BaseProductController.cs`: the union of - today's three controllers' action logic, with every `StaffStoreId`/ - `HasAccessToProduct` call site replaced by calls into the injected - `IAdminDataScope`. -- Three per-host `ProductController : BaseProductController` subclasses, - reduced to `[Area(...)]` + constructor pass-through, matching - `LoginController`. -- `Grand.Web.AdminShared/Services/ProductViewModelService.cs` gains whatever - Vendor's copy has that AdminShared's doesn't. Each real difference found in - the 1768-line diff must be attributed to a scope decision (`IAdminDataScope`) - or ported as shared behavior — never copy-pasted as a parallel branch. - Vendor's own `Services/ProductViewModelService.cs` is deleted; Vendor starts - consuming AdminShared's, as Store already does. -- Existing characterization tests are the migration's correctness gate: they - move to (or are consolidated into) `Grand.Web.AdminShared.Tests`, plus thin - per-host tests that check only routing/authorization attributes. New unit - tests cover the three `IAdminDataScope` implementations directly. -- Phase 1 ships as an independently mergeable, fully working change — no - half-migrated state, no flag. - -## Phase 2 — View consolidation - -- `Product/*.cshtml` views (51 Admin / 51 Store / 49 Vendor) move to - `Grand.Web.AdminShared/Views/Product/`. -- `Grand.Web.Common/View/ViewLocationExpander.cs` (today handles only the - storefront `ThemeKey` case) gains an admin-area branch: when the executing - controller derives from `BaseProductController`, AdminShared's view folder - is added as a fallback location. -- Views whose only difference is the hardcoded area string - (`Constants.AreaAdmin`/`AreaStore`/`AreaVendor`) are unified into one - AdminShared view using the request's current area instead. -- Views with a real functional difference (e.g. Admin-only bulk export panel - on `List.cshtml`) stay as host-specific overrides, resolved before the - AdminShared fallback by the expander. -- Phase 2 depends on Phase 1 (needs `BaseProductController` to exist as the - branch condition) but is its own mergeable unit with its own review - checkpoint — work can pause between phases without leaving a broken or - half-migrated state. - -## Testing - -- Phase 1: run and green all migrated/consolidated `ProductControllerTests` - and `ProductViewModelServiceTests` (Admin/Store/Vendor), plus new - `IAdminDataScope` unit tests. -- Phase 2: manual/characterization pass over rendered Product screens per - host (List, Create, Edit, and the tabs/partials with known host-specific - content) to confirm the expander resolves views correctly and overrides - render where expected. - -## Out of scope - -- Any entity other than Product (Order, Category, Collection, etc.) — future - work, enabled but not started by this spec. -- Merging the three hosts into a single deployable app — explicitly rejected - in the ARCH-001 finding; auth models, data scopes, and independent - deployability stay separate. -- A generalized `IAdminAreaContext` covering area name + capability flags — - considered (design option C) and deferred as speculative beyond what - Product needs today. diff --git a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md b/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md deleted file mode 100644 index 48d5ec0785..0000000000 --- a/docs/superpowers/specs/2026-08-17-arch001-phase2-view-consolidation-design.md +++ /dev/null @@ -1,634 +0,0 @@ -# ARCH-001 Phase 2 — Product view consolidation design - -Date: 2026-08-17 -Status: Approved, ready for implementation planning - -## Problem - -Phase 1 (`docs/superpowers/specs/2026-08-16-arch001-product-consolidation-design.md`, -implemented on branch `arch001/phase1-product-consolidation`, PR #790) consolidated -`ProductController` and `ProductViewModelService` into `Grand.Web.AdminShared`, -reducing each host's controller to a ~20-70 line subclass of `BaseProductController`. -The one duplication Phase 1 explicitly deferred is views: `Grand.Web.Admin`, -`Grand.Web.Store`, and `Grand.Web.Vendor` each still carry their own copy of -`Product/*.cshtml` (51 / 51 / 49 files, ~9300 / ~9060 / ~8600 lines). Spot-check -diff of `List.cshtml` (Admin vs Store) confirms the same pattern Phase 1 found in -controllers: some files differ only in a hardcoded area string or resource-key -prefix, others have a real functional difference (Admin's bulk export/import/ -delete panel and `SearchStoreId`/`SearchVendorId` filters are absent from Store's -`List.cshtml`; Vendor lacks `CreateOrUpdate.Discounts.cshtml` and -`CreateOrUpdate.Documents.cshtml` entirely). - -This spec covers **only the `Product` view set**, the second and final slice of -the Phase 1/2 split already anticipated in the Phase 1 spec's "Phase 2 — View -consolidation" section. It supersedes that section with a concrete, checked -design. - -## Existing precedent - -Plugins in this repo already compile Razor views into their own assembly and -have them discovered at runtime — e.g. `src/Plugins/DiscountRules.Standard/ -DiscountRules.Standard.csproj` uses `Sdk="Microsoft.NET.Sdk.Razor"` with -`true`. Per project memory -`reference_running_the_storefront`, "Plugin views compile into the plugin DLL." -This is the same mechanism ASP.NET Core uses for Razor Class Libraries consumed -via `ProjectReference` (MSBuild auto-generates a `RelatedAssembly` attribute on -the consuming project, and `ApplicationPartManager` auto-discovers the -referenced assembly's compiled views) — no plugin-loading machinery is needed -for this case since `Grand.Web.AdminShared` is already a compile-time -`ProjectReference` from all three hosts. - -`Grand.Web.Common/View/ViewLocationExpander.cs` already implements one -conditional, additive branch (`ThemeKey`, for storefront theme overrides). This -design adds a second, independent branch to the same class rather than -introducing a new expander. - -## Goals - -- Delete the ~150-file, ~27000-line-total duplication the same way Phase 1 - deleted the controller/service duplication: one canonical copy per view, - living in `Grand.Web.AdminShared`, with host-specific overrides only where a - real functional difference exists. -- No change to deployability: each host stays independently buildable and - deployable; views arrive via the existing `ProjectReference`, not a new - packaging or runtime-discovery mechanism. -- Host-specific views continue to render as they do today — this is a pure - dedup, not a UX change (aside from the deliberate, already-known Store/Vendor - feature gaps captured in Phase 1's controller work). - -## Non-goals - -- No new automated test infrastructure. This repo has no `WebApplicationFactory` - usage anywhere (confirmed by search) and host startup is gated on - `DataSettingsManager.DatabaseIsInstalled()`, meaning a real integration-test - harness would need Mongo (e.g. Testcontainers) — a project of its own. Out of - scope here; verification stays the manual/characterization pass the Phase 1 - spec already anticipated. -- No splitting of host-specific-difference views into shared skeleton + partial - override. A view with a real functional difference stays a whole-file, - host-specific override. Revisit only if a future file turns out to be >80% - identical with one small differing block — decide per-file during migration, - default to whole-file override (YAGNI). -- No change to non-Product views. Order/Category/Collection view consolidation - is future work enabled, not started, by this design (same boundary Phase 1 - drew for controllers/services). -- No generalized `IAdminAreaContext` or view-model changes — this is a view - file relocation plus one expander branch, nothing in `BaseProductController` - or `ProductViewModelService` changes. - -## Design - -### 1. `Grand.Web.AdminShared` becomes a Razor Class Library - -Change `src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj`: - -```xml - - - - enable - true - - - -``` - -No other project in the solution needs to change how it references AdminShared -— the three hosts already have a `ProjectReference` to it from Phase 1. - -### 2. View location: `Grand.Web.AdminShared/Views/Product/*.cshtml` - -Not under an `Areas/` folder — AdminShared has no area of its own. The relative -path a Razor view compiles under (`/Views/Product/List.cshtml`) becomes its -lookup key application-wide, independent of which assembly compiled it. - -### 3. `ViewLocationExpander` gets a second, independent branch - -`src/Web/Grand.Web.Common/View/ViewLocationExpander.cs`, in -`ExpandViewLocations`: - -```csharp -public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, - IEnumerable viewLocations) -{ - if (context.Values.TryGetValue(ThemeKey, out _)) - { - var viewFactory = context.ActionContext.HttpContext.RequestServices.GetRequiredService(); - viewFactory.GetViewPath(context.AreaName ?? "", ref viewLocations); - } - - if (IsAdminSharedController(context.ActionContext.ActionDescriptor)) - viewLocations = viewLocations.Append("/Views/{1}/{0}.cshtml"); - - return viewLocations; -} - -private static bool IsAdminSharedController(ActionDescriptor descriptor) -{ - if (descriptor is not ControllerActionDescriptor cad) return false; - for (var t = cad.ControllerTypeInfo.AsType(); t is not null; t = t.BaseType) - if (t.Namespace == "Grand.Web.AdminShared.Controllers") - return true; - return false; -} -``` - -Generic namespace check, not a hardcoded `BaseProductController` reference — -Phase 3 (Order, Category, ...) gets the fallback automatically the moment a -`Base*Controller` lands in that namespace, with zero further change to this -file. The `Append` (not prepend) is what makes host-specific overrides win: -`RazorViewEngine` tries each location in order and returns the first file that -exists, so a host's own `Areas/{Area}/Views/Product/X.cshtml` — which appears -earlier in the default location list — always wins over the AdminShared -fallback when both exist. - -The two branches (`ThemeKey` / AdminShared) are independent and additive — -Grand.Web (storefront) has no `Grand.Web.AdminShared.Controllers`-derived -controllers, and Admin/Store/Vendor have no theme context, so in practice at -most one branch ever fires per request. - -### 3a. Layout resolution (addendum, found while drafting the pilot task) - -`List.cshtml`/`Create.cshtml`/`Edit.cshtml` (and most other Product views) set -no `Layout` themselves — they inherit it from each host's own -`Areas/{Area}/Views/_ViewStart.cshtml` (e.g. -`src/Web/Grand.Web.Admin/Areas/Admin/Views/_ViewStart.cshtml` sets -`Layout = Constants.Layout_Admin`). Razor's `_ViewStart.cshtml` discovery walks -up from the **resolved logical path the view was found under**, not from the -requesting controller's area. A view resolved through the new fallback -location (`/Views/Product/List.cshtml`, inside `Grand.Web.AdminShared`) walks -up `/Views/Product/` → `/Views/` → `/` looking for `_ViewStart.cshtml` there — -it never sees `/Areas/Admin/Views/_ViewStart.cshtml`, so `Layout` would be left -unset and the page would render with no host chrome. - -Fix: add `src/Web/Grand.Web.AdminShared/Views/_ViewStart.cshtml`: - -```cshtml -@{ - var area = Context.GetRouteValue("area")?.ToString(); - Layout = $"~/Areas/{area}/Views/Shared/_{area}Layout.cshtml"; -} -``` - -The three hosts' layout files already follow this exact naming convention -(`_AdminLayout.cshtml`, `_StoreLayout.cshtml`, `_VendorLayout.cshtml`, confirmed -via `Constants.Layout_Admin`/`LayoutStore`/`LayoutVendor` in each host's own -`Extensions/Constants.cs`) and stay put in each host — only `Views/Product/*` -moves. The `~/`-rooted path resolves against the full merged view-location -provider (all `ApplicationPart`s, including the executing host's own compiled -views), so it finds the host's own layout correctly regardless of which -assembly the `_ViewStart.cshtml` itself lives in. - -### 3b. `_ViewImports.cshtml` for the shared view folder (addendum) - -Each host's `Areas/{Area}/Views/_ViewImports.cshtml` brings in the tag helpers -and `@inject`s a migrated view needs (`Loc` for resource lookups, -`EnumTranslationService`). The tag helpers Product views actually use -(`admin-input`, `admin-select`, `admin-label`, etc.) come from -`@addTagHelper *, Grand.Web.Common` — already common to all three hosts' -`_ViewImports.cshtml`, not from any host-specific tag helper assembly — so a -single shared import file covers them. Add -`src/Web/Grand.Web.AdminShared/Views/_ViewImports.cshtml`: - -```cshtml -@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@addTagHelper *, Grand.Web.Common - -@using System.Globalization -@using Microsoft.AspNetCore.Http.Extensions -@using Microsoft.AspNetCore.Mvc.ViewFeatures -@using System.Text -@using Grand.SharedKernel.Extensions -@using Grand.Infrastructure -@using Grand.Domain.Common -@using Grand.Domain.Catalog -@using Grand.Domain.Directory -@using Grand.Web.Common -@using Grand.Web.Common.Extensions -@using Grand.Web.Common.Localization -@using Grand.Web.AdminShared.Models.Catalog -@using Grand.Web.AdminShared.Interfaces - -@inject LocService Loc -@inject IEnumTranslationService EnumTranslationService -@inject IAdminDataScope Scope -``` - -Injecting `Scope` at the `_ViewImports` level (not per-file) means every -migrated view gets `Scope.ResourceKeyPrefix` (for the `Admin.*`/`Vendor.*` -resource-key split, same as `BaseProductController`'s Phase 1 pattern) and -`ViewContext.RouteData.Values["area"]` (read per-file where an -`asp-area="@Constants.AreaAdmin"` literal needs replacing) without repeating -the `@inject` line in all ~50 files. If a per-file migration needs a tag -helper or using not in this list, add it here rather than to the individual -file, unless it's genuinely single-file-specific. - -### 4. Per-file migration classification - -For each of the ~53 distinct Product view filenames (union of the three -hosts), read all present variants and classify: - -| Case | Resolution | -|---|---| -| Byte-identical, or differs only in a hardcoded area string / resource-key prefix already unified behind `IAdminDataScope` in Phase 1 | One file in `AdminShared/Views/Product/`, using `ViewContext.RouteData.Values["area"]` (or the equivalent existing helper) instead of a literal `Constants.AreaAdmin`/`AreaStore`/`AreaVendor`; delete the 2-3 host copies. | -| Differs only by a capability flag Phase 1 already introduced (e.g. `Model.ShowStoreSelector`, `scope.ResourceKeyPrefix`) | One file with the existing conditional (`@if (Model.ShowStoreSelector) { ... }`); delete host copies. | -| Real functional difference (Admin-only bulk export/import/delete panel and store/vendor search filters on `List.cshtml`; Vendor missing `Discounts`/`Documents` partials entirely) | Stays as a whole-file, host-specific override in that host's own `Areas/{Area}/Views/Product/` folder. Not moved to AdminShared. | - -This mirrors Phase 1's Task 8/10 discipline: one file (or a tightly-coupled -small group, e.g. `CreateOrUpdate.*.cshtml` region partials with a shared -parent) per checklist row, each read across all present hosts, classified, -migrated, and committed independently — subagent-driven-development, one -subagent per row. - -### 5. Verification - -- `dotnet build GrandNode.sln` after the RCL conversion and after each - migration batch — a missing view at runtime is a startup-time or - render-time failure, not a compile error, so build success alone is not - sufficient evidence. -- Manual/characterization pass per host (per the original Phase 1 spec's - Testing section): List → Create → Edit → Save for an existing product, - once per host (Admin/Store/Vendor), confirming the page renders with the - expected host-specific content (or lack thereof) and no - `InvalidOperationException: The view '...' was not found` error. -- Existing MSTest suites (`Grand.Web.Admin.Tests`, `Grand.Web.Store.Tests`, - `Grand.Web.Vendor.Tests`) stay green throughout — they don't render Razor - views today (confirmed: no `WebApplicationFactory` usage in the repo), so - they are a regression guard for the controller/service layer this touches - incidentally (e.g. if a `.cshtml` move breaks a `[ViewComponent]` or model - binding), not a substitute for the manual pass above. - -### 4a. Widget-zone selection: per-area partial files, not an inline `@if` (addendum, 2026-08-18) - -Early Task 4 batches unified views containing a widget-zone tag-helper call -(`` vs ``) using an inline conditional: - -```cshtml -@if (Scope.ResourceKeyPrefix == "Vendor") -{ - -} -else -{ - -} -``` - -Superseded. Widget-zone selection now uses a small, per-occurrence partial -resolved through the same host-override-wins mechanism section 3 already -established, instead of a C# branch inside the unified file: - -- `src/Web/Grand.Web.AdminShared/Views/Product/Partials/WidgetZone..cshtml` - holds the Admin/Store-shared default: ``. -- `src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone..cshtml` - holds Vendor's override: ``. -- The parent unified view calls `` in place of the old `@if` block. Admin and Store naturally - fall through to the AdminShared default (no host-specific file needed for - them, since they share it); Vendor's own `Areas/Vendor/...` copy is found - first by `RazorViewEngine` and wins, exactly like any other host override - under section 3 — no new expander logic needed. -- `` is a short, occurrence-specific PascalCase name derived from the - zone-name pair with the common `product_`/`vendor_product_` prefix and any - `vendor_` prefix stripped (e.g. `product_bulk_edit_buttons` / - `vendor_product_bulk_edit_buttons` → `WidgetZone.BulkEditButtons.cshtml`; - `product_details_bids_top` / `vendor_product_details_bids_top` → - `WidgetZone.Bids.Top.cshtml`). Pick a name that reads clearly next to its - sibling occurrences in the same parent file (e.g. `Bids.Top`/`Bids.Bottom` - for the two zones inside `CreateOrUpdate.Bids.cshtml`). - -Rationale: `Scope.ResourceKeyPrefix`-branching was already established for -genuinely mixed content (a whole tab present in some hosts, per section 3's -`CreateOrUpdate.cshtml` case) where no other mechanism fits cleanly. For -widget-zone selection specifically — a single self-contained tag-helper call -repeated at ~20+ sites — a per-area file keeps each host's markup physically -separate and lets the existing view-resolution precedence do the selection, -rather than growing every unified file's branch count. This also means a -future widget-zone-only change to one host never touches the shared parent -file at all. - -All files already unified under the old inline-`@if` pattern get retrofitted -to this one in the same implementation pass that introduces it (tracked in -the plan's Task 4 as a one-time batch), so the codebase never carries both -patterns side by side once that pass lands. - -## Out of scope - -- Automated view-rendering tests (`WebApplicationFactory`, Testcontainers-backed - Mongo) — noted above as a real gap, but its own project; revisit separately - if the manual pass proves too costly to repeat as Phase 3+ lands. -- Any entity other than Product. -- Merging the three hosts into one deployable app (same standing rejection as - Phase 1). - -## 5. Post-review corrections (2026-08-18) - -A final whole-branch review inspected the *compiled* assemblies and the combined -`Grand.Web` host, and found two render-time regressions that neither the diff nor -`dotnet build` could show. Both are fixed on this branch. **Sections 2, 3a, 3b and -4a above are superseded on the two points below** — read this section as the -current truth. - -### 5.1 Shared views live under `/Views/AdminShared/…`, not `/Views/…` (supersedes section 2) - -Section 2 asserted that a view's compiled path "becomes its lookup key -application-wide" without checking that key against existing occupancy. A Razor -view path is global across *every* `ApplicationPart`, and `Grand.Web` — the -combined host — references `Grand.Web.{Admin,Store,Vendor}` and therefore -transitively loads AdminShared's views alongside its own storefront views. Two -real collisions existed: `/Views/_ViewStart.cshtml` (AdminShared's admin-layout -resolver vs. the storefront's `Layout = "_Layout"`) and -`/Views/Product/Partials/ProductAttributes.cshtml` (admin `ProductModel` partial -vs. the storefront product-details partial). One silently shadows the other and -the loser fails at render time; which one wins depends on application-part order. - -Corrected layout: - -- `src/Web/Grand.Web.AdminShared/Views/AdminShared/{Controller}/*.cshtml` -- `src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml` -- `src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewStart.cshtml` -- `ViewLocationExpander.AdminSharedFallbackLocation` = `/Views/AdminShared/{1}/{0}.cshtml` - -The `_ViewStart` ancestor walk still resolves -(`/Views/AdminShared/Product/X.cshtml` → `/Views/AdminShared/_ViewStart.cshtml`) -and host-override precedence is unchanged. - -**Rule for Phase 3 and later:** every shared entity folder goes under -`Views/AdminShared/`. The `AdminShared` segment is owned by no other project, so -`Order/`, `Vendor/`, `Page/`, `Blog/`, `News/` and `Catalog/` — all of which -already exist under `src/Web/Grand.Web/Views/` — cannot collide. - -### 5.2 Widget-zone defaults live in `Grand.Web.Admin`, not AdminShared (amends section 4a) - -Section 4a placed the Admin/Store-shared widget-zone default in -`AdminShared/Views/Product/Partials/WidgetZone..cshtml`. That does not -work: `` binds to `AdminWidgetViewComponent` in -`Grand.Web.Admin`, and Razor binds tag helpers **at compile time** from the -compiling project's `@addTagHelper` set. `Grand.Web.AdminShared` only adds -`Grand.Web.Common`, so all 44 widget-zone calls in the moved views compiled to -literal `` markup — no compile error, no warning, no runtime -exception, just dead zones and a stray custom element per site. Verified in the -built assembly: 44 literal `.cshtml` | the real `` | -| `Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone..cshtml` | the real `` (unchanged) | -| `Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone..cshtml` | an empty `@* … *@` placeholder, which Store falls through to | - -The parent shared view still calls ``; selection still happens purely through the section-3 override -precedence. The empty AdminShared placeholder preserves Store's exact -pre-existing behaviour (Store has no widget component and never rendered these -zones) while making it deliberate instead of accidental. - -**Rule:** a Razor construct that binds to a *host's* tag helper or view component -cannot live in `Grand.Web.AdminShared`. Only markup whose tag helpers come from -`Grand.Web.Common` is shareable. The cheap mechanical check is that -`Grand.Web.AdminShared.dll` must contain zero literal ``, a `
` - pair, a whole `
` section, a Kendo column-definition object): extract the - block into `Partials/.cshtml`, called unconditionally from the parent. AdminShared's copy - holds the real content (Admin+Store); a same-named file under - `Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/.cshtml` is empty (an `@* ... *@` - comment, mirroring the empty-placeholder precedent from section 5.2) and wins for Vendor through - ordinary view-location precedence. No C# conditional remains in the parent file. - - `CreateOrUpdate.cshtml` → `Partials/Tab.Documents.cshtml`, `Partials/Tab.UserFields.cshtml` - (Documents additionally still needs the `ManageDocuments` permission check — that check moves - into the AdminShared partial itself, since it's an authorization concern, not a host-shape - one; Vendor's empty override doesn't need it, it never had the tab). - - `CreateOrUpdateTierPrice.cshtml` → `Partials/TierPrice.StoreScope.cshtml`. - - `CreateOrUpdate.Additional.cshtml` → `Partials/Additional.Downloads.cshtml`. - - `CreateOrUpdate.Categories.cshtml` / `CreateOrUpdate.Collections.cshtml` (the - `IsFeaturedProduct` column) → `Partials/Categories.FeaturedColumn.cshtml` / - `Partials/Collections.FeaturedColumn.cshtml`. -2. **Content that differs rather than disappears** (the Kendo `template:` link-vs-plain-text - swap): extract just the differing fragment into its own partial, both hosts get a real file. - - `CreateOrUpdate.Categories.cshtml` → `Partials/Categories.LinkTemplate.cshtml` (AdminShared: - `template: '#:Category#'`; Vendor: - `template: '#:Category#'`). - - `CreateOrUpdate.Collections.cshtml` → `Partials/Collections.LinkTemplate.cshtml`, same shape. - - `CreateOrUpdate.Reviews.cshtml` (found during implementation - not in the original six-site - audit, which only searched for `!= "Vendor"` and missed this file's `== "Vendor"` phrasing of - the same idiom) → `Partials/Reviews.CustomerLinkTemplate.cshtml` and - `Partials/Reviews.TitleLinkTemplate.cshtml`, same shape, two occurrences in one file. -3. **C# capability gate** (no partial mechanism applies outside Razor): add a named boolean to - `IAdminDataScope` instead of comparing `ResourceKeyPrefix`, matching the pattern - `ShowStoreSelector`/`DefaultVendorId` already establish for capability flags. - - Add `bool CanFeatureOnHomepage { get; }` — `true` on `GlobalAdminDataScope` and - `StoreAdminDataScope`, `false` on `VendorProductDataScope`. - - `ProductViewModelService.cs:577` becomes `if (scope.CanFeatureOnHomepage)`, and the comment - explaining the old string-comparison workaround is deleted (the workaround it warned about is - gone). - -### Verification - -- `dotnet build GrandNode.sln`. -- `Grand.Web.AdminShared.dll` still contains zero literal `.cshtml` files with the comment "Store has no equivalent widget zone." That claim -was checked against Store's *original* (pre-Phase-2) Product views, which is where it went wrong: -those original files called `` — copy-pasted from -Admin without adapting it — and Store's `_ViewImports.cshtml` never added `@addTagHelper *, -Grand.Web.Admin`, so that tag helper was already dead literal markup in Store's own pre-migration -code (confirmed via `git show` on the pre-migration commit). Section 5.2's placeholder therefore -preserved a genuine pre-existing bug rather than the intended behavior: `Grand.Web.Store` has its -own `StoreWidgetViewComponent` (`Grand.Web.Store/Components/StoreWidget.cs`), registered as -`vc:store-widget`, actively used elsewhere in Store's own views -(`Areas/Store/Views/Home/Index.cshtml`, `Statistics.cshtml`, `Shared/_StoreLayout.cshtml`) with a -`store_`-prefixed zone-name convention (`store_dashboard_top`, `store_header_before`, ...) — it was -simply never wired into Product's widget zones, likely since the Store panel's Product screens were -first added. - -### Resolution - -Same three-tier table as section 5.2, extended with Store's own row - Store gets the same treatment -Vendor already has, using its own established `store_` prefix (mirrors `vendor_product_X`): - -| File | Content | -|---|---| -| `Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone..cshtml` | `` (unchanged) | -| `Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone..cshtml` | **new:** `` | -| `Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone..cshtml` | `` (unchanged, 40 of 44 - Vendor has no Discounts/Documents tabs) | -| `Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone..cshtml` | empty placeholder (unchanged content, comment corrected - now genuinely unreachable except as the Discounts/Documents fallback for Vendor) | - -All 44 zones apply to Store (Store shows the same tabs as Admin; only Vendor's 4 gaps differ), so -Store gets all 44, generated mechanically from Admin's 44 real files by substituting -`vc:admin-widget` → `vc:store-widget` and prefixing each `widget-zone` value with `store_` - the -same transform already proven correct by Vendor's `vendor_` prefix. New zone names -(`store_product_...`) are brand new; no widget plugin currently targets them, so this changes no -visible behavior today - it makes the extension point reachable for future (or existing, -not-yet-Product-scoped) Store widget plugins, exactly like Vendor's equivalent zones were reachable -but likely unpopulated when they were added. - -The three pre-existing Store-specific whole-file overrides kept outside AdminShared per section 4 -(`CreateOrUpdate.{Info,Prices,PurchasedWithOrders}.cshtml`, kept because each has a real grid/column -difference from Admin) carried the same dead `vc:admin-widget` copy-paste and got the same fix in -the same pass, since they're Store's own files either way. - -`Grand.Web.AdminShared.dll` keeps zero literal `` markup, checked the same way section 5.2 checked Admin/Vendor. - -**Rule for Phase 3:** when checking "does host X have an equivalent mechanism" during a -consolidation, check host X's *own* codebase for the real answer (does it have a widget component, -is it used elsewhere, what does `_ViewImports.cshtml` actually import) - don't infer it from what -the entity's *original*, pre-consolidation views for that host happened to contain. A copy-pasted, -never-adapted call is evidence of a pre-existing bug, not evidence the host lacks the capability. - -## 8. Remove `IAdminDataScope.ApplyScope` (addendum, 2026-08-19) - -### Problem - -`ApplyScope(IQueryable query)` was part of the original Phase 1 interface design but never -became load-bearing: `BaseProductController` and `ProductViewModelService` scope every read -(`SearchProducts`) and write path through the `storeId`/`vendorId` parameters and `HasAccess`/ -`CanView` checks instead, never through an `IQueryable` filter. Confirmed by grep across `src/Web` -and `src/Tests`: the only callers of `ApplyScope` were its own unit tests -(`GlobalAdminDataScopeTests.ApplyScope_ReturnsQueryUnchanged`, -`VendorProductDataScopeTests.ApplyScope_FiltersToOwnVendorId`) - dead production code advertising a -scoping mechanism nothing uses, flagged during the tenant-isolation audit in section 6/7's session -and removed on request rather than left to accumulate. - -### Resolution - -Removed the member from `IAdminDataScope` and its four implementations -(`GlobalAdminDataScope`, `StoreAdminDataScope`, `VendorProductDataScope`, -`RoutedProductDataScope`'s pass-through), plus the two tests that only existed to exercise it. No -other code referenced it (grep clean after removal). `IStoreLinkEntity`/`Stores`/`LimitedToStores` -filtering logic that lived inside `StoreAdminDataScope.ApplyScope` is not reproduced elsewhere - it -was never called, so there is nothing to preserve. - -**If Phase 3 needs query-level scoping** (e.g. a list endpoint that filters via `IQueryable` instead -of passing a `storeId`/`vendorId` parameter into a service method, the way Product does), add the -member back on the entity/host where it is actually wired to a caller in the same change - not -speculatively ahead of a caller, which is what happened here. - -### Verification - -`dotnet build GrandNode.sln` clean. `Grand.Web.Admin.Tests`/`Grand.Web.Store.Tests`/ -`Grand.Web.Vendor.Tests`: 419/33/8 (down 2 from the removed `ApplyScope` tests), all green. - -## 9. CodeQL: "missing CSRF token validation" on `BaseProductController.cs` (addendum, 2026-08-19) - -### Investigation - -CodeQL flagged `BaseProductController.cs`'s `[HttpPost]` actions as missing antiforgery validation. -Checked: `BaseProductController` is `abstract` (never directly routable) and extends -`Grand.Web.Common.Controllers.BaseController`, which carries no antiforgery attribute. Its three -concrete subclasses (`Grand.Web.Admin`/`Grand.Web.Store`/`Grand.Web.Vendor`'s `ProductController`) -each already declare `[AutoValidateAntiforgeryToken]` at the class level - restated there since Phase -1 Task 11 explicitly because `BaseProductController` "can't inherit any single host's base controller" -(each host's own `BaseAdminController`/`BaseStoreController`/`BaseVendorController`, which normally -supplies it, differs by `[Area]`/`[Authorize*]`). ASP.NET Core resolves MVC filters from the full type -hierarchy of the concrete controller at request time, so every actual runtime endpoint (there are only -these three concrete subclasses - grep confirmed) is already protected. This is a static-analysis -false positive in the sense that no exploitable gap exists today - CodeQL's query doesn't follow an -attribute from a derived class in a different project back onto the base class where the actions are -textually defined. - -### Fix - -Added `[AutoValidateAntiforgeryToken]` directly to `BaseProductController` too. This changes no -runtime behavior (redundant with the three subclasses' own copies, and the base class was never -routable anyway), but removes a real fragility the false-positive investigation surfaced: protection -depended entirely on every current *and future* host subclass remembering to restate the attribute, -with nothing enforcing it at the point where the actions actually live. Also gives CodeQL's static -analysis something to see in the same file it flagged. - -### Verification - -`dotnet build GrandNode.sln` clean. `Grand.Web.Admin.Tests`/`Grand.Web.Store.Tests`/ -`Grand.Web.Vendor.Tests`: 419/33/8, unchanged, all green. From f830ae74f0c2a2f835fc76eb6e5eb30f72e35e48 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 23 Aug 2026 16:22:12 +0200 Subject: [PATCH 145/147] Add @model ProductModel consistently across Product WidgetZone.*.cshtml Of the 44 Product WidgetZone.*.cshtml files, 12 declared @model ProductModel (added incidentally in earlier passes) and 29 didn't - both groups called additional-data="Model" and worked identically (Razor treats an undeclared Model as dynamic), just inconsistent style inherited from the original per-host files. Added @model ProductModel to the 29 files x 3 hosts (Admin/Store/ Vendor) = 87 files that call additional-data="Model" but lacked it. Left the 3 files x 3 hosts = 9 that call additional-data="null" (BulkEditButtons, TierPrice.Buttons, AttributeValueButtons) without one - their parent view never passes a model="Model" argument because none of those contexts have a single ProductModel in scope. ProductModel resolves via each host's own _ViewImports.cshtml, no per-file @using needed. Spec section 10. Verified: dotnet build GrandNode.sln clean; Admin/Store/Vendor tests 419/33/8 unchanged, all green. Co-Authored-By: Claude Sonnet 5 --- .../Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Additional.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml | 1 + .../Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml | 1 + .../Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml | 1 + .../Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml | 1 + .../Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml | 1 + .../Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml | 1 + .../Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Top.cshtml | 1 + .../Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Additional.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml | 1 + .../Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml | 1 + .../Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml | 1 + .../Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml | 1 + .../Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml | 1 + .../Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml | 1 + .../Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Top.cshtml | 1 + .../Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Additional.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml | 1 + .../Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml | 1 + .../Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml | 1 + .../Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml | 1 + .../Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml | 1 + .../Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml | 1 + .../Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Categories.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Collections.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.DetailsButtons.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Inventory.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml | 1 + .../Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Bottom.cshtml | 1 + .../WidgetZone.ProductAttributes.Combinations.Top.cshtml | 1 + .../Product/Partials/WidgetZone.ProductAttributes.Top.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml | 1 + .../Views/Product/Partials/WidgetZone.Recommended.Top.cshtml | 1 + .../Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml | 1 + 87 files changed, 87 insertions(+) diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml index b2bea2c08d..3eb6382d46 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml index d9af4164c2..0de5077bc2 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml index 365803772c..c689c8caf4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml index 41ac70608f..6a4f1a801c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml index 2890283570..df341bc290 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml index 5d2805eddb..ab481085f2 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml index 30a6fd57cd..f229db5a07 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml index 6393243e7e..55035187a1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml index cd795bb689..45d653435a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml index a4ab3e0fd6..251a09296a 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml index 30ece34a2e..27dc7ffae5 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml index 2372482976..a13716f2ca 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml index 843f509a38..794ead6654 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml index 2feef39d30..3cfbffb411 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml index ad2b99fdac..63c55836aa 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml index 293613eb81..3ed01211c0 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml index 5347c99b88..80b46d669c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml index 2f59d184d9..42997cf591 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml index 94f1a73ec3..557199023c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml index 3b6542cd1c..f5f524ced9 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml index b57690790e..b0ce1c8462 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml index 04182faca6..7133c46ec0 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml index 4725ad56fb..1e4841da34 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml index c0add9776e..333668bfc4 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml index b4bfd091fb..9f48290fcf 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml index 76a0a3507d..926e786329 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml index b32a3fa65a..a01328dfd0 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml index 1694947c39..27cdbacf24 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml index 8d3c1910cd..17b179aa18 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml index 657c33f199..e1f883eda9 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml index 2d12d92095..7cd271cad1 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml index 0890b978c8..bb689e6122 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml index 072085f56c..85433d6c4d 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml index 74b81c766e..149d057484 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml index 2270ed663e..3114450be0 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml index 9a67758711..aedb71f2a4 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml index dbab7835e8..d1350516a6 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml index 7d3c8a7cbd..a8b6748b08 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml index ccf7b4d9bb..54c503a79b 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml index b058798be8..9402ef60eb 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml index 48992e35dd..3d6de63e98 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml index 974b3b685b..84b8f36fba 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml index 834cade30d..c6bcc6c94f 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml index 78224914c7..21a42edbb0 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml index 34a9fe0797..2492ba081a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml index 011c45a903..a111dcf552 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml index e11fa97019..f263528ba0 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml index 20dfe57338..43d63cf65e 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml index 553d19e44e..3e3839d36a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml index 7e8d6adab8..33c7b59fcd 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml index ad280ff6c2..0322395d40 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml index f663c11f3c..fb0c71d1e4 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml index 6403f5ee86..733f1af467 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml index d6ee90b5cc..7158d20285 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml index 9f61f2e22e..4da25a5579 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml index 7306849015..ec7386b2d5 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml index f6d8a36921..bbf9437b2c 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml index 2313bdde5f..59e68b1e2a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml index 3d204ba76c..b8cd9db140 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml index 6cc0b2dc49..a5df149d26 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Additional.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml index ae7a301596..f5404790ac 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml index b2b5c3dd7e..094557a588 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AssociatedProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml index e082a6fdfe..f5192564a4 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeDetailsTabs.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml index ed26e90c96..8287bcaf7d 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml index 284cc1952c..9255dd56b5 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Bids.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml index 688b877d01..f64bc21e5f 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml index b077ea59ea..aa746a2e4b 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.BundleProducts.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml index b254d1d2d4..8e7de1cdfb 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml index 6d2816fbf7..84512ca1ae 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Calendar.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml index b9fa131aa3..035468c5fc 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml index 259e4ebd18..684cdabf73 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Categories.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml index 650253d902..f79d976563 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml index 8b9a94d201..b4f586f498 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Collections.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml index 89ba7bb89b..53287ec625 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml index 55fd8043f1..e914e04d8e 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.CrossSells.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml index 16625af2e1..89feb65ec0 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.DetailsButtons.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml index 4ba492dab5..3dad792bf7 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml index ac3addd2e1..09171dbf9a 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Inventory.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml index 34f7c8d324..1a3099be96 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml index 9f4f751951..12f0c10892 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Pictures.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml index f901fec236..db50a5377b 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml index 96ea2a878a..7cc9aa0485 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml index 00044da29c..4b5b926d0a 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Combinations.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml index d402547650..bb847e142a 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.ProductAttributes.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml index 9239fad113..911bed67fc 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Bottom.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml index fc738ef76c..182693bec9 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Recommended.Top.cshtml @@ -1 +1,2 @@ +@model ProductModel diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml index 31ef3c8a0b..ce8a26fea9 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.Tabs.cshtml @@ -1 +1,2 @@ +@model ProductModel From f2e1c61a8c0ba88644c3948114b47d79125b20bd Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 23 Aug 2026 19:30:03 +0200 Subject: [PATCH 146/147] Minor fix --- .../Product/Partials/Categories.FeaturedColumn.cshtml | 2 -- .../Product/Partials/Collections.FeaturedColumn.cshtml | 2 -- .../Product/Partials/CreateOrUpdate.Categories.cshtml | 4 ++-- .../Product/Partials/CreateOrUpdate.Collections.cshtml | 4 ++-- .../Product/Partials/CreateOrUpdate.Reviews.cshtml | 4 ++-- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml index 997f469d5e..b46a801f7f 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Categories.FeaturedColumn.cshtml @@ -1,4 +1,3 @@ - { field: "IsFeaturedProduct", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Categories.Fields.IsFeaturedProduct"]", @@ -7,4 +6,3 @@ attributes: { style: "text-align:center" }, template: '# if(IsFeaturedProduct) {# #} else {# #} #' }, - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml index d79d6177b2..6490a61ddc 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/Collections.FeaturedColumn.cshtml @@ -1,4 +1,3 @@ - { field: "IsFeaturedProduct", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.IsFeaturedProduct"]", @@ -7,4 +6,3 @@ attributes: { style: "text-align:center" }, template: '# if(IsFeaturedProduct) {# #} else {# #} #' }, - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml index 332003813f..0842555f6a 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml @@ -97,9 +97,9 @@ title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Categories.Fields.Category"]", width: 200, editor: categoryDropDownEditor, - + @await Html.PartialAsync("Partials/Categories.LinkTemplate") }, - + @await Html.PartialAsync("Partials/Categories.FeaturedColumn") { field: "DisplayOrder", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Categories.Fields.DisplayOrder"]", diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml index f90ac00f0b..acd14f577b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Collections.cshtml @@ -98,9 +98,9 @@ title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.Collection"]", width: 200, editor: collectionDropDownEditor, - + @await Html.PartialAsync("Partials/Collections.LinkTemplate") }, - + @await Html.PartialAsync("Partials/Collections.FeaturedColumn") { field: "DisplayOrder", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Collections.Fields.DisplayOrder"]", diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml index 52813d42be..fc014f5ea0 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Reviews.cshtml @@ -52,13 +52,13 @@ field: "CustomerId", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.ProductReviews.Fields.Customer"]", width: 200, - + @await Html.PartialAsync("Partials/Reviews.CustomerLinkTemplate") minScreenWidth: 750, }, { field: "Title", title: "@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.ProductReviews.Fields.Title"]", - + @await Html.PartialAsync("Partials/Reviews.TitleLinkTemplate") width: 280, }, { From 9d136c71283d8d62b9b27955b03f9d2b06628782 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 23 Aug 2026 19:57:20 +0200 Subject: [PATCH 147/147] Remove unused file --- .../Startup/StartupApplication.cs | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/Web/Grand.Web.Store/Startup/StartupApplication.cs diff --git a/src/Web/Grand.Web.Store/Startup/StartupApplication.cs b/src/Web/Grand.Web.Store/Startup/StartupApplication.cs deleted file mode 100644 index ff01045b75..0000000000 --- a/src/Web/Grand.Web.Store/Startup/StartupApplication.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Grand.Infrastructure; - -namespace Grand.Web.Store.Startup; - -public class StartupApplication : IStartupApplication -{ - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - // IAdminDataScope is registered once, centrally, by Grand.Web.AdminShared's own - // StartupApplication via RoutedProductDataScope - see its doc comment. Registering it here - // too would race with Admin's/Vendor's registrations under the combined Grand.Web host. - } - - public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) - { - } - - public int Priority => 101; - public bool BeforeConfigure => false; -}