1. Bertrand Le Roy
  2. Nwazet.Commerce

Commits

Bertrand Le Roy  committed 6e2a4fa

Product attributes

  • Participants
  • Parent commits 68d66c5
  • Branches default

Comments (0)

Files changed (39)

File Controllers/AttributesAdminController.cs

View file
  • Ignore whitespace
+using System.Linq;
+using System.Web.Mvc;
+using Nwazet.Commerce.Models;
+using Nwazet.Commerce.ViewModels;
+using Orchard.ContentManagement;
+using Orchard.Core.Title.Models;
+using Orchard.DisplayManagement;
+using Orchard.Environment.Extensions;
+using Orchard.Localization;
+using Orchard.Settings;
+using Orchard.UI.Admin;
+using Orchard.UI.Navigation;
+
+namespace Nwazet.Commerce.Controllers {
+    [Admin]
+    [OrchardFeature("Nwazet.Attributes")]
+    public class AttributesAdminController : Controller {
+        private dynamic Shape { get; set; }
+        private readonly ISiteService _siteService;
+        private readonly IContentManager _contentManager;
+
+        public AttributesAdminController(
+            IContentManager contentManager,
+            ISiteService siteService,
+            IShapeFactory shapeFactory) {
+
+            _contentManager = contentManager;
+            _siteService = siteService;
+
+            Shape = shapeFactory;
+            T = NullLocalizer.Instance;
+        }
+
+
+        public Localizer T { get; set; }
+        
+        public ActionResult Index(PagerParameters pagerParameters) {
+            var pager = new Pager(_siteService.GetSiteSettings(), pagerParameters.Page, pagerParameters.PageSize);
+            var attributes = _contentManager
+                .Query<ProductAttributePart>()
+                .Join<TitlePartRecord>()
+                .OrderBy(p => p.Title)
+                .List();
+            var paginatedAttributes = attributes
+                .Skip(pager.GetStartIndex())
+                .Take(pager.PageSize)
+                .ToList();
+            var pagerShape = Shape.Pager(pager).TotalItemCount(attributes.Count());
+            var vm = new AttributesIndexViewModel {
+                Attributes = paginatedAttributes,
+                Pager = pagerShape
+            };
+
+            return View(vm);
+        }
+    }
+}

File Controllers/ShoppingCartController.cs

View file
  • Ignore whitespace
         }
 
         [HttpPost]
-        public ActionResult Add(int id, int quantity) {
-            _shoppingCart.Add(id, quantity);
+        public ActionResult Add(int id, int quantity, IDictionary<int, string> productattributes) {
+            // Workaround MVC bug that won't correctly bind an empty dictionary
+            if (productattributes.Count == 1 && productattributes.Values.First() == "__none__") {
+                productattributes = null;
+            }
+            _shoppingCart.Add(id, quantity, productattributes);
             if (Request.IsAjaxRequest()) {
                 return new ShapePartialResult(this, BuildCartShape(true));
             }
                     Product: productQuantity.Product,
                     Sku: productQuantity.Product.Sku,
                     Title: _contentManager.GetItemMetadata(productQuantity.Product).DisplayText,
+                    ProductAttributes: productQuantity.AttributeIdsToValues,
                     ContentItem: (productQuantity.Product).ContentItem,
                     ProductImage: ((MediaPickerField)productQuantity.Product.Fields.FirstOrDefault(f => f.Name == "ProductImage")),
                     IsDigital: productQuantity.Product.IsDigital,
                          {
                              id = productQuantity.Product.Id,
                              title = productQuantity.Product is IContent ? _contentManager.GetItemMetadata((IContent)productQuantity.Product).DisplayText : productQuantity.Product.Sku,
+                             productAttributes = productQuantity.AttributeIdsToValues,
                              unitPrice = productQuantity.Product.Price,
                              quantity = productQuantity.Quantity
                          }).ToArray()
 
             _shoppingCart.AddRange(items
                 .Where(item => !item.IsRemoved)
-                .Select(item => new ShoppingCartItem(item.ProductId, item.Quantity < 0 ? 0 : item.Quantity))
+                .Select(item => new ShoppingCartItem(
+                    item.ProductId, 
+                    item.Quantity < 0 ? 0 : item.Quantity,
+                    item.AttributeIdsToValues))
             );
 
             _shoppingCart.UpdateItems();

File Drivers/ProductAttributePartDriver.cs

View file
  • Ignore whitespace
+using System;
+using JetBrains.Annotations;
+using Nwazet.Commerce.Models;
+using Orchard;
+using Orchard.ContentManagement;
+using Orchard.ContentManagement.Drivers;
+using Orchard.ContentManagement.Handlers;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Drivers {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributePartDriver : ContentPartDriver<ProductAttributePart> {
+
+        public ProductAttributePartDriver(
+            IOrchardServices services) {
+
+            Services = services;
+        }
+
+        public IOrchardServices Services { get; set; }
+
+        protected override string Prefix { get { return "NwazetCommerceAttribute"; } }
+
+        protected override DriverResult Display(
+            ProductAttributePart part, string displayType, dynamic shapeHelper) {
+            // The attribute part should never appear on the front-end.
+            return null;
+        }
+
+        //GET
+        protected override DriverResult Editor(ProductAttributePart part, dynamic shapeHelper) {
+            return ContentShape(
+                "Parts_ProductAttribute_Edit",
+                () => shapeHelper.EditorTemplate(
+                    TemplateName: "Parts/ProductAttribute",
+                    Prefix: Prefix,
+                    Model: part));
+        }
+
+        //POST
+        protected override DriverResult Editor(ProductAttributePart part, IUpdateModel updater, dynamic shapeHelper) {
+            var editViewModel = new ProductAttributeEditViewModel();
+            if (updater.TryUpdateModel(editViewModel, Prefix, null, null)) {
+                part.Record.AttributeValues = editViewModel.AttributeValues;
+            }
+            return Editor(part, shapeHelper);
+        }
+
+        private class ProductAttributeEditViewModel {
+            public string AttributeValues { get; [UsedImplicitly] set; }
+        }
+
+        protected override void Importing(ProductAttributePart part, ImportContentContext context) {
+            var values = context.Attribute(part.PartDefinition.Name, "Values");
+            if (!String.IsNullOrWhiteSpace(values)) {
+                part.Record.AttributeValues = values;
+            }
+        }
+
+        protected override void Exporting(ProductAttributePart part, ExportContentContext context) {
+            context.Element(part.PartDefinition.Name).SetAttributeValue("Values", part.Record.AttributeValues);
+        }
+    }
+}

File Drivers/ProductAttributesPartDriver.cs

View file
  • Ignore whitespace
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.Annotations;
+using Nwazet.Commerce.Models;
+using Nwazet.Commerce.Services;
+using Nwazet.Commerce.ViewModels;
+using Orchard;
+using Orchard.ContentManagement;
+using Orchard.ContentManagement.Drivers;
+using Orchard.ContentManagement.Handlers;
+using Orchard.Core.Title.Models;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Drivers {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributesPartDriver : ContentPartDriver<ProductAttributesPart>, IProductAttributesDriver {
+        private readonly IContentManager _contentManager;
+
+        public ProductAttributesPartDriver(IContentManager contentManager) {
+            _contentManager = contentManager;
+        }
+
+        protected override string Prefix { get { return "NwazetCommerceAttribute"; } }
+
+        protected override DriverResult Display(
+            ProductAttributesPart part, string displayType, dynamic shapeHelper) {
+
+            // The shape is acquired by the product driver so it can be included into the add to cart shape
+            return null;
+        }
+
+        public dynamic GetAttributeDisplayShape(IContent product, dynamic shapeHelper) {
+            var attributesPart = product.As<ProductAttributesPart>();
+            var attributes = attributesPart == null ? null : _contentManager
+                .GetMany<ProductAttributePart>(
+                    attributesPart.AttributeIds,
+                    VersionOptions.Published,
+                    new QueryHints().ExpandParts<TitlePart>());
+            return shapeHelper.Parts_ProductAttributes(
+                ContentItem: product,
+                ProductAttributes: attributes
+                );
+        }
+
+        public bool ValidateAttributes(IContent product, IDictionary<int, string> attributeIdsToValues) {
+            var attributesPart = product.As<ProductAttributesPart>();
+            // If the part isn't there, there must be no attributes
+            if (attributesPart == null) return attributeIdsToValues == null || !attributeIdsToValues.Any();
+            // If the part is there, it must have as many attributes as were passed in
+            if (attributesPart.AttributeIds.Count() != attributeIdsToValues.Count) return false;
+            // The same attributes must be present
+            if (!attributesPart.AttributeIds.All(attributeIdsToValues.ContainsKey)) return false;
+            // Get the actual attributes in order to verify the values
+            var attributes = _contentManager.GetMany<ProductAttributePart>(
+                attributeIdsToValues.Keys, 
+                VersionOptions.Published, 
+                QueryHints.Empty)
+                .ToList();
+            // The values that got passed in must exist
+            return attributes.All(attribute => attribute.AttributeValues.Contains(attributeIdsToValues[attribute.Id]));
+        }
+
+        //GET
+        protected override DriverResult Editor(ProductAttributesPart part, dynamic shapeHelper) {
+            return ContentShape(
+                "Parts_ProductAttributes_Edit",
+                () => shapeHelper.EditorTemplate(
+                    TemplateName: "Parts/ProductAttributes",
+                    Prefix: Prefix,
+                    Model: new ProductAttributesPartEditViewModel {
+                        Prefix = Prefix,
+                        Part = part,
+                        Attributes = _contentManager
+                        .Query<ProductAttributePart>(VersionOptions.Published)
+                        .Join<TitlePartRecord>()
+                        .OrderBy(p => p.Title)
+                        .List()
+                    }));
+        }
+
+        //POST
+        protected override DriverResult Editor(ProductAttributesPart part, IUpdateModel updater, dynamic shapeHelper) {
+            var editViewModel = new ProductAttributesEditViewModel();
+            if (updater.TryUpdateModel(editViewModel, Prefix, null, null)) {
+                part.AttributeIds = editViewModel.AttributeIds;
+            }
+            return Editor(part, shapeHelper);
+        }
+
+        private class ProductAttributesEditViewModel {
+            public int[] AttributeIds { get; [UsedImplicitly] set; }
+        }
+
+        protected override void Importing(ProductAttributesPart part, ImportContentContext context) {
+            var values = context.Attribute(part.PartDefinition.Name, "Ids");
+            if (!String.IsNullOrWhiteSpace(values)) {
+                part.Record.Attributes = values;
+            }
+        }
+
+        protected override void Exporting(ProductAttributesPart part, ExportContentContext context) {
+            context.Element(part.PartDefinition.Name).SetAttributeValue("Ids", part.Record.Attributes);
+        }
+    }
+}

File Drivers/ProductPartDriver.cs

View file
  • Ignore whitespace
     public class ProductPartDriver : ContentPartDriver<ProductPart> {
         private readonly IWorkContextAccessor _wca;
         private readonly IPriceService _priceService;
+        private readonly IEnumerable<IProductAttributesDriver> _attributeProviders;
 
-        public ProductPartDriver(IWorkContextAccessor wca, IPriceService priceService) {
+        public ProductPartDriver(
+            IWorkContextAccessor wca, 
+            IPriceService priceService,
+            IEnumerable<IProductAttributesDriver> attributeProviders) {
+
             _wca = wca;
             _priceService = priceService;
+            _attributeProviders = attributeProviders;
         }
 
         protected override string Prefix { get { return "NwazetCommerceProduct"; } }
                     productShape,
                     ContentShape(
                         "Parts_Product_AddButton",
-                        () => shapeHelper.Parts_Product_AddButton(ProductId: part.Id))
+                        () => {
+                            // Get attributes and add them to the add to cart shape
+                            var attributeShapes = _attributeProviders
+                                .Select(p => p.GetAttributeDisplayShape(part.ContentItem, shapeHelper))
+                                .ToList();
+                            return shapeHelper.Parts_Product_AddButton(
+                                ProductId: part.Id,
+                                ProductAttributes: attributeShapes);
+                        })
                     );
             }
             return productShape;

File Handlers/AttributePartHandler.cs

View file
  • Ignore whitespace
+using Nwazet.Commerce.Models;
+using Orchard.ContentManagement.Handlers;
+using Orchard.Data;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Handlers {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class AttributePartHandler : ContentHandler {
+        public AttributePartHandler(IRepository<ProductAttributePartRecord> repository) {
+            Filters.Add(StorageFilter.For(repository));
+        }
+    }
+}

File Handlers/AttributesPartHandler.cs

View file
  • Ignore whitespace
+using Nwazet.Commerce.Models;
+using Orchard.ContentManagement.Handlers;
+using Orchard.Data;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Handlers {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class AttributesPartHandler : ContentHandler {
+        public AttributesPartHandler(IRepository<ProductAttributesPartRecord> repository) {
+            Filters.Add(StorageFilter.For(repository));
+        }
+    }
+}

File Menus/AttributeAdminMenu.cs

View file
  • Ignore whitespace
+using Orchard.Environment.Extensions;
+using Orchard.Localization;
+using Orchard.UI.Navigation;
+
+namespace Nwazet.Commerce.Menus {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class AttributeAdminMenu : INavigationProvider {
+        public string MenuName {
+            get { return "admin"; }
+        }
+
+        public AttributeAdminMenu() {
+            T = NullLocalizer.Instance;
+        }
+
+        private Localizer T { get; set; }
+
+        public void GetNavigation(NavigationBuilder builder) {
+            builder
+                .AddImageSet("nwazet-commerce")
+                .Add(item => item
+                    .Caption(T("Commerce"))
+                    .Position("2")
+                    .LinkToFirstChild(true)
+
+                    .Add(subItem => subItem
+                        .Caption(T("Attributes"))
+                        .Position("2.2")
+                        .Action("Index", "AttributesAdmin", new { area = "Nwazet.Commerce" })
+                    )
+                );
+        }
+    }
+}

File Migrations/AttributesMigrations.cs

View file
  • Ignore whitespace
+using Orchard.ContentManagement.MetaData;
+using Orchard.Data.Migration;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Migrations {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class AttributesMigrations : DataMigrationImpl {
+
+        public int Create() {
+            SchemaBuilder.CreateTable("ProductAttributePartRecord", table => table
+                .ContentPartRecord()
+                .Column<string>("AttributeValues", col => col.Unlimited())
+            );
+
+            SchemaBuilder.CreateTable("ProductAttributesPartRecord", table => table
+                .ContentPartRecord()
+                .Column<string>("Attributes")
+            );
+
+            ContentDefinitionManager.AlterTypeDefinition("ProductAttribute", cfg => cfg
+                .WithPart("TitlePart")
+                .WithPart("ProductAttributePart"));
+
+            ContentDefinitionManager.AlterTypeDefinition("Product", cfg => cfg
+                .WithPart("ProductAttributesPart"));
+
+            return 1;
+        }
+    }
+}

File Models/IShoppingCart.cs

View file
  • Ignore whitespace
 namespace Nwazet.Commerce.Models {
     public interface IShoppingCart : IDependency {
         IEnumerable<ShoppingCartItem> Items { get; }
-        void Add(int productId, int quantity = 1);
+        void Add(int productId, int quantity = 1, IDictionary<int, string> attributeIdsToValues = null);
         void AddRange(IEnumerable<ShoppingCartItem> items);
-        void Remove(int productId);
-        ProductPart GetProduct(int productId);
+        void Remove(int productId, IDictionary<int, string> attributeIdsToValues = null);
         IEnumerable<ShoppingCartQuantityProduct> GetProducts();
+        ShoppingCartItem FindCartItem(int productId, IDictionary<int, string> attributeIdsToValues);
         void UpdateItems();
         double Subtotal();
         double Taxes();

File Models/ProductAttributePart.cs

View file
  • Ignore whitespace
+using System;
+using System.Collections.Generic;
+using Orchard.ContentManagement;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Models {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributePart: ContentPart<ProductAttributePartRecord> {
+        public IEnumerable<string> AttributeValues {
+            get {
+                return Record.AttributeValues == null
+                    ? new string[0]
+                    : Record.AttributeValues.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+            }
+            set {
+                Record.AttributeValues = value == null
+                                             ? null
+                                             : String.Join("\r\n", value);
+            }
+        }
+    }
+}

File Models/ProductAttributePartRecord.cs

View file
  • Ignore whitespace
+using Orchard.ContentManagement.Records;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Models {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributePartRecord : ContentPartRecord {
+        public virtual string AttributeValues { get; set; }
+    }
+}

File Models/ProductAttributesPart.cs

View file
  • Ignore whitespace
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Orchard.ContentManagement;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Models {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributesPart: ContentPart<ProductAttributesPartRecord> {
+        public IEnumerable<int> AttributeIds {
+            get {
+                return Record.Attributes == null 
+                    ? new int[0] 
+                    : Record.Attributes
+                    .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries)
+                    .Select(int.Parse);
+            }
+            set {
+                Record.Attributes = value == null
+                                             ? null
+                                             : String.Join(",", value);
+            }
+        }
+    }
+}

File Models/ProductAttributesPartRecord.cs

View file
  • Ignore whitespace
+using Orchard.ContentManagement.Records;
+using Orchard.Environment.Extensions;
+
+namespace Nwazet.Commerce.Models {
+    [OrchardFeature("Nwazet.Attributes")]
+    public class ProductAttributesPartRecord : ContentPartRecord {
+        public virtual string Attributes { get; set; }
+    }
+}

File Models/ShoppingCart.cs

View file
  • Ignore whitespace
 using System.Linq;
 using Nwazet.Commerce.Services;
 using Orchard.ContentManagement;
+using Orchard.Core.Title.Models;
 using Orchard.Environment.Extensions;
 
 namespace Nwazet.Commerce.Models {
         private readonly IContentManager _contentManager;
         private readonly IShoppingCartStorage _cartStorage;
         private readonly IPriceService _priceService;
+        private readonly IEnumerable<IProductAttributesDriver> _attributesDrivers;
 
         public ShoppingCart(
             IContentManager contentManager,
             IShoppingCartStorage cartStorage,
-            IPriceService priceService) {
+            IPriceService priceService,
+            IEnumerable<IProductAttributesDriver> attributesDrivers) {
 
             _contentManager = contentManager;
             _cartStorage = cartStorage;
             _priceService = priceService;
+            _attributesDrivers = attributesDrivers;
         }
 
         public IEnumerable<ShoppingCartItem> Items {
             }
         }
 
-        public void Add(int productId, int quantity = 1) {
-            var item = Items.SingleOrDefault(x => x.ProductId == productId);
-
-            if (item == null) {
-                item = new ShoppingCartItem(productId, quantity);
-                ItemsInternal.Add(item);
+        public void Add(int productId, int quantity = 1, IDictionary<int, string> attributeIdsToValues = null) {
+            ValidateAttributes(productId, attributeIdsToValues);
+            var item = FindCartItem(productId, attributeIdsToValues);
+            if (item != null) {
+                item.Quantity += quantity;
             }
             else {
-                item.Quantity += quantity;
+                ItemsInternal.Add(new ShoppingCartItem(productId, quantity, attributeIdsToValues));
+            }
+        }
+
+        public ShoppingCartItem FindCartItem(int productId, IDictionary<int, string> attributeIdsToValues = null) {
+            if (attributeIdsToValues == null || attributeIdsToValues.Count == 0) {
+                return Items.FirstOrDefault(i => i.ProductId == productId
+                      && (i.AttributeIdsToValues == null || i.AttributeIdsToValues.Count == 0));
+            }
+            return Items.FirstOrDefault(
+                i => i.ProductId == productId
+                     && i.AttributeIdsToValues != null
+                     && i.AttributeIdsToValues.Count == attributeIdsToValues.Count
+                     && i.AttributeIdsToValues.All(attributeIdsToValues.Contains));
+        }
+
+        private void ValidateAttributes(int productId, IDictionary<int, string> attributeIdsToValues) {
+            if (_attributesDrivers == null || attributeIdsToValues == null || !attributeIdsToValues.Any()) return;
+            var product = _contentManager.Get(productId);
+            if (_attributesDrivers.Any(d => !d.ValidateAttributes(product, attributeIdsToValues))) {
+                // Throwing because this should only happen from malicious payloads
+                throw new ArgumentException("Invalid product attributes", "attributeIdsToValues");
             }
         }
 
         public void AddRange(IEnumerable<ShoppingCartItem> items) {
             foreach (var item in items) {
-                Add(item.ProductId, item.Quantity);
+                Add(item.ProductId, item.Quantity, item.AttributeIdsToValues);
             }
         }
 
-        public void Remove(int productId) {
-            var item = Items.SingleOrDefault(x => x.ProductId == productId);
+        public void Remove(int productId, IDictionary<int, string> attributeIdsToValues = null) {
+            var item = FindCartItem(productId, attributeIdsToValues);
             if (item == null) return;
 
             ItemsInternal.Remove(item);
         }
 
-        public ProductPart GetProduct(int productId) {
-            return _contentManager.Get(productId).As<ProductPart>();
-        }
-
         public IEnumerable<ShoppingCartQuantityProduct> GetProducts() {
             var ids = Items.Select(x => x.ProductId);
 
             var productParts =
-                _contentManager.GetMany<ProductPart>(ids, VersionOptions.Latest, QueryHints.Empty).ToArray();
+                _contentManager.GetMany<ProductPart>(ids, VersionOptions.Published, new QueryHints().ExpandParts<TitlePart>()).ToArray();
 
             var shoppingCartQuantities =
                 (from item in Items
-                 from product in productParts
-                 where product.Id == item.ProductId
-                 select new ShoppingCartQuantityProduct(item.Quantity, product))
+                 select new ShoppingCartQuantityProduct(item.Quantity, productParts.First(p => p.Id == item.ProductId), item.AttributeIdsToValues))
                     .ToList();
 
             return shoppingCartQuantities

File Models/ShoppingCartItem.cs

View file
  • Ignore whitespace
 using System;
+using System.Collections.Generic;
+using System.Linq;
 
 namespace Nwazet.Commerce.Models {
     [Serializable]
         private int _quantity;
 
         public int ProductId { get; private set; }
+        public IDictionary<int, string> AttributeIdsToValues { get; set; }
 
         public int Quantity {
             get { return _quantity; }
 
         public ShoppingCartItem() {}
 
-        public ShoppingCartItem(int productId, int quantity = 1) {
+        public ShoppingCartItem(int productId, int quantity = 1, IDictionary<int, string> attributeIdsToValues = null) {
             ProductId = productId;
             Quantity = quantity;
+            AttributeIdsToValues = attributeIdsToValues;
+        }
+
+        public string AttributeDescription {
+            get {
+                if (AttributeIdsToValues == null || !AttributeIdsToValues.Any()) {
+                    return "";
+                }
+                return "(" + string.Join(", ", AttributeIdsToValues.Values) + ")";
+            }
+        }
+
+        public override string ToString() {
+            return "{" + Quantity + " x " + ProductId
+                + (string.IsNullOrWhiteSpace(AttributeDescription) ? "" : " " + AttributeDescription)
+                + "}";
         }
     }
 }

File Models/ShoppingCartQuantityProduct.cs

View file
  • Ignore whitespace
-namespace Nwazet.Commerce.Models {
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Nwazet.Commerce.Models {
     public class ShoppingCartQuantityProduct {
-        public ShoppingCartQuantityProduct(int quantity, ProductPart product) {
+        public ShoppingCartQuantityProduct(int quantity, ProductPart product, IDictionary<int, string> attributeIdsToValues = null) {
             Quantity = quantity;
             Product = product;
             Price = product.Price;
+            AttributeIdsToValues = attributeIdsToValues;
         }
 
         public int Quantity { get; private set; }
         public ProductPart Product { get; private set; }
         public double Price { get; set; }
         public string Comment { get; set; }
+        public IDictionary<int, string> AttributeIdsToValues { get; set; }
+
+        public string AttributeDescription {
+            get {
+                if (AttributeIdsToValues == null || !AttributeIdsToValues.Any()) {
+                    return "";
+                }
+                return "(" + string.Join(", ", AttributeIdsToValues.Values) + ")";
+            }
+        }
 
         public override string ToString() {
-            return "{" + Quantity + " " + Product.Sku + " ($" + Price + ")}";
+            return "{" + Quantity + " " + Product.Sku 
+                + (string.IsNullOrWhiteSpace(AttributeDescription) ? "" : " " + AttributeDescription) 
+                + " at $" + Price + "}";
         }
     }
 }

File Module.txt

View file
  • Ignore whitespace
         Dependencies: Nwazet.Shipping
     Nwazet.Referrals:
         Name: Nwazet Referrals
-        Description: Keeps track of the domain the user came from, in order to honor partner referral fees.
+        Description: Keeps track of the domain the user came from, in order to honor partner referral fees
         Category: Commerce
     Nwazet.Promotions:
         Name: Nwazet Promotions
         Description: Apply promotions to your products
         Category: Commerce
         Dependencies: Orchard.Roles
+    Nwazet.Attributes:
+        Name: Nwazet Product Attributes
+        Description: Enables customers to customize a product when they add it to the cart
+        Category: Commerce

File Nwazet.Commerce.Tests/Nwazet.Commerce.Tests.csproj

View file
  • Ignore whitespace
     <Reference Include="System.Xml.Linq" />
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="ProductAttributeTests.cs" />
     <Compile Include="Stubs\ContentManagerStub.cs" />
+    <Compile Include="Stubs\ProductAttributeStub.cs" />
     <Compile Include="Stubs\DiscountStub.cs" />
     <Compile Include="Stubs\FakeCartStorage.cs" />
     <Compile Include="Stubs\FakeClock.cs" />

File Nwazet.Commerce.Tests/PriceProviderTests.cs

View file
  • Ignore whitespace
                 }
             };
             var priceService = new PriceService(priceProviders);
-            var cart = new ShoppingCart(contentManager, cartStorage, priceService);
+            var cart = new ShoppingCart(contentManager, cartStorage, priceService, null);
             FillCart(cart);
 
             return cart;

File Nwazet.Commerce.Tests/ProductAttributeTests.cs

View file
  • Ignore whitespace
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using Nwazet.Commerce.Drivers;
+using Nwazet.Commerce.Models;
+using Nwazet.Commerce.Services;
+using Nwazet.Commerce.Tests.Stubs;
+using Orchard.ContentManagement;
+
+namespace Nwazet.Commerce.Tests {
+    [TestFixture]
+    public class ProductAttributeTests {
+        // Cart content for all those tests:
+        // 3 x $ 10 (Black, L)
+        // 1 x $ 10 (Black, M)
+        // 7 x $ 10 (Blue, XS)
+        // 6 x $1.5
+        // 5 x $ 20 (S)
+        // --------
+        //     $219
+
+        [Test]
+        public void FindCartItemFindsItemWithNoAttributes() {
+            var cart = PrepareCart();
+
+            var item = cart.FindCartItem(2);
+            Assert.That(item.AttributeIdsToValues, Is.Null);
+            Assert.That(item.ProductId, Is.EqualTo(2));
+            Assert.That(item.Quantity, Is.EqualTo(6));
+        }
+
+        [Test]
+        public void FindCartItemDoesntFindItemWithNoAttributesWhenSpecifyingAttributes() {
+            var cart = PrepareCart();
+
+            Assert.That(cart.FindCartItem(2, new Dictionary<int, string> {{10, "Green"}}), Is.Null);
+        }
+
+        [Test]
+        public void FindCartItemDoesntFindItemWithAttributesWhenSpecifyingNoAttributes() {
+            var cart = PrepareCart();
+
+            Assert.That(cart.FindCartItem(1), Is.Null);
+            Assert.That(cart.FindCartItem(1, new Dictionary<int, string>()), Is.Null);
+        }
+
+        [Test]
+        public void FindCartItemDoesntFindItemWithAttributesWhenSpecifyingWrongAttributes() {
+            var cart = PrepareCart();
+
+            Assert.That(cart.FindCartItem(1, new Dictionary<int, string> { { 10, "Red" }, { 11, "M" } }), Is.Null);
+            Assert.That(cart.FindCartItem(1, new Dictionary<int, string> { { 10, "Green" }, { 11, "S" } }), Is.Null);
+        }
+
+        [Test]
+        public void FindCartItemFindsItemWithAttributes() {
+            var cart = PrepareCart();
+
+            var item = cart.FindCartItem(1, new Dictionary<int, string> { { 10, "Green" }, { 11, "L" } });
+            Assert.That(item.ToString(), Is.EqualTo("{3 x 1 (Green, L)}"));
+            item = cart.FindCartItem(1, new Dictionary<int, string> { { 10, "Green" }, { 11, "M" } });
+            Assert.That(item.ToString(), Is.EqualTo("{1 x 1 (Green, M)}"));
+            item = cart.FindCartItem(1, new Dictionary<int, string> { { 10, "Blue" }, { 11, "XS" } });
+            Assert.That(item.ToString(), Is.EqualTo("{7 x 1 (Blue, XS)}"));
+            item = cart.FindCartItem(3, new Dictionary<int, string> { { 11, "S" } });
+            Assert.That(item.ToString(), Is.EqualTo("{5 x 3 (S)}"));
+        }
+
+        [Test]
+        public void CheckCartWorksOnOriginalCart() {
+            var cart = PrepareCart();
+
+            CheckCart(cart, OriginalQuantities);
+        }
+
+        [Test]
+        public void AddProductWithoutAttributesAddsToExistingQuantity() {
+            var cart = PrepareCart();
+
+            cart.Add(2, 3);
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(9, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+            });
+        }
+
+        [Test]
+        public void AddProductWithAttributesAddsToExistingQuantity() {
+            var cart = PrepareCart();
+
+            cart.Add(1, 1, new Dictionary<int, string> { { 10, "Green" }, { 11, "M" } });
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(2, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(6, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+            });
+        }
+
+        [Test]
+        public void AddProductWithDifferentAttributesAddsToExistingQuantityCreatesLine() {
+            var cart = PrepareCart();
+
+            cart.Add(1, 2, new Dictionary<int, string> { { 10, "Red" }, { 11, "M" } });
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(2, Products[0], new Dictionary<int, string> {{10, "Red" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(6, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+            });
+        }
+
+        [Test]
+        public void AddProductWithoutAttributesThatsNotAlreadyThereCreatesLine() {
+            var cart = PrepareCart();
+
+            cart.Add(4, 8);
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(6, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}}),
+                new ShoppingCartQuantityProduct(8, Products[3])
+            });
+        }
+
+        [Test]
+        public void AddProductWithAttributesThatsNotAlreadyThereCreatesLine() {
+            var cart = PrepareCart();
+
+            cart.Add(5, 8, new Dictionary<int, string> {{10, "Red"}, {11, "M"}});
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(6, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}}),
+                new ShoppingCartQuantityProduct(8, Products[4], new Dictionary<int, string> {{10, "Red"}, {11, "M"}})
+            });
+        }
+
+        [Test]
+        public void RemoveProductWithAttributesRemovesLine() {
+            var cart = PrepareCart();
+
+            cart.Remove(1, new Dictionary<int, string> { { 10, "Green" }, { 11, "M" } });
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(6, Products[1]), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+            });
+        }
+
+        [Test]
+        public void RemoveProductWithoutAttributesRemovesLine() {
+            var cart = PrepareCart();
+
+            cart.Remove(2);
+
+            CheckCart(cart, new[] {
+                new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "L" }}), 
+                new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green" }, { 11, "M" }}), 
+                new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue" }, { 11, "XS" }}), 
+                new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+            });
+        }
+
+        [Test]
+        public void RemoveProductWithNonExistingAttributeValuesDoesNothing() {
+            var cart = PrepareCart();
+
+            cart.Remove(1, new Dictionary<int, string> { { 10, "Red" }, { 11, "M" } });
+
+            CheckCart(cart, OriginalQuantities);
+        }
+
+        [Test]
+        [ExpectedException(typeof(ArgumentException))]
+        public void AddProductWithBadAttributeValueThrows() {
+            var cart = PrepareCart();
+
+            cart.Add(1, 8, new Dictionary<int, string> {{10, "NotAValidColor"}, {11, "M"}});
+        }
+
+        [Test]
+        [ExpectedException(typeof(ArgumentException))]
+        public void AddProductWithTooFewAttributeValueThrows() {
+            var cart = PrepareCart();
+
+            cart.Add(1, 8, new Dictionary<int, string> { { 11, "M" } });
+        }
+
+        [Test]
+        [ExpectedException(typeof(ArgumentException))]
+        public void AddProductWithTooManyAttributeValueThrows() {
+            var cart = PrepareCart();
+
+            cart.Add(2, 8, new Dictionary<int, string> { { 10, "Green" }, { 11, "M" } });
+        }
+
+        [Test]
+        [ExpectedException(typeof(ArgumentException))]
+        public void AddProductWithAttributeValuesWhereProductHasNoneThrows() {
+            var cart = PrepareCart();
+
+            cart.Add(2, 8, new Dictionary<int, string> { { 11, "M" } });
+        }
+
+        [Test]
+        [ExpectedException(typeof(ArgumentException))]
+        public void AddProductWithDifferentAttributesThrows() {
+            var cart = PrepareCart();
+
+            cart.Add(2, 8, new Dictionary<int, string> { { 10, "Green" } });
+        }
+
+        private static readonly ProductStub[] Products = new[] {
+            new ProductStub(1, new[] {10, 11}) {Price = 10},
+            new ProductStub(2, new int[0]) {Price = 1.5},
+            new ProductStub(3, new[] {11}) {Price = 20},
+            new ProductStub(4, new int[0]) {Price = 15},
+            new ProductStub(5, new[] {10, 11}) {Price = 27} 
+        };
+
+        private static readonly ProductAttributeStub[] ProductAttributes = new[] {
+            new ProductAttributeStub(10, "Green", "Blue", "Red"),
+            new ProductAttributeStub(11, "XS", "S", "M", "L", "XL", "XXL")
+        };
+
+        private static readonly ShoppingCartQuantityProduct[] OriginalQuantities = new[] {
+            new ShoppingCartQuantityProduct(3, Products[0], new Dictionary<int, string> {{10, "Green"}, {11, "L"}}), 
+            new ShoppingCartQuantityProduct(1, Products[0], new Dictionary<int, string> {{10, "Green"}, {11, "M"}}), 
+            new ShoppingCartQuantityProduct(7, Products[0], new Dictionary<int, string> {{10, "Blue"}, {11, "XS"}}), 
+            new ShoppingCartQuantityProduct(6, Products[1]), 
+            new ShoppingCartQuantityProduct(5, Products[2], new Dictionary<int, string> {{11, "S"}})
+        };
+
+        private static void FillCart(IShoppingCart cart) {
+            cart.AddRange(OriginalQuantities
+                .Select(q => new ShoppingCartItem(q.Product.Id, q.Quantity, q.AttributeIdsToValues)));
+        }
+
+        private static ShoppingCart PrepareCart() {
+            var contentManager = new ContentManagerStub(Products.Cast<IContent>().Union(ProductAttributes));
+            var cartStorage = new FakeCartStorage();
+            var priceService = new PriceService(new IPriceProvider[0]);
+            var attributeDriver = new ProductAttributesPartDriver(contentManager);
+            var cart = new ShoppingCart(contentManager, cartStorage, priceService, new[] {attributeDriver});
+            FillCart(cart);
+
+            return cart;
+        }
+
+        private static void CheckCart(IShoppingCart cart, IEnumerable<ShoppingCartQuantityProduct> expectedQuantities) {
+            const double epsilon = 0.001;
+            var expectedQuantityList = expectedQuantities.ToList();
+            var expectedSubTotal = Math.Round(expectedQuantityList.Sum(q => q.Quantity * Math.Round(q.Product.Price, 2)), 2);
+            Assert.That(Math.Abs(cart.Subtotal() - expectedSubTotal), Is.LessThan(epsilon));
+            var cartContents = cart.GetProducts().ToList();
+            Assert.That(cartContents.Count == expectedQuantityList.Count());
+            foreach (var shoppingCartQuantityProduct in cartContents) {
+                var product = cart.FindCartItem(
+                    shoppingCartQuantityProduct.Product.Id, shoppingCartQuantityProduct.AttributeIdsToValues);
+                Assert.That(product.Quantity, Is.EqualTo(shoppingCartQuantityProduct.Quantity));
+            }
+        }
+    }
+}

File Nwazet.Commerce.Tests/Stubs/ProductAttributeStub.cs

View file
  • Ignore whitespace
+using System.Collections.Generic;
+using Nwazet.Commerce.Models;
+using Orchard.ContentManagement;
+using Orchard.ContentManagement.Records;
+
+namespace Nwazet.Commerce.Tests.Stubs {
+    public class ProductAttributeStub : ProductAttributePart {
+        public ProductAttributeStub(int id, params string[] attributeValues) {
+            Record = new ProductAttributePartRecord();
+            AttributeValues = attributeValues;
+            ContentItem = new ContentItem {
+                VersionRecord = new ContentItemVersionRecord {
+                    ContentItemRecord = new ContentItemRecord()
+                },
+                ContentType = "ProductAttribute"
+            };
+            ContentItem.Record.Id = id;
+            ContentItem.Weld(this);
+        }
+    }
+}

File Nwazet.Commerce.Tests/Stubs/ProductStub.cs

View file
  • Ignore whitespace
-using System.Web.Routing;
+using System.Collections.Generic;
 using Nwazet.Commerce.Models;
 using Orchard.ContentManagement;
 using Orchard.ContentManagement.Records;
 
 namespace Nwazet.Commerce.Tests.Stubs {
     public class ProductStub : ProductPart {
-        public ProductStub(int id = -1) {
+        public ProductStub(int id = -1, IEnumerable<int> attributeIds = null) {
             Record = new ProductPartRecord();
             ShippingCost = -1;
             ContentItem = new ContentItem {
             };
             ContentItem.Record.Id = id;
             ContentItem.Weld(this);
+            if (attributeIds != null) {
+                var attrPartRecord = new ProductAttributesPartRecord();
+                var attrPart = new ProductAttributesPart {
+                    Record = attrPartRecord
+                };
+                attrPart.AttributeIds = attributeIds;
+                ContentItem.Weld(attrPart);
+            }
         }
 
-        public ProductStub(int id, string path) : this(id) {
+        public ProductStub(int id, string path, IEnumerable<int> attributeIds = null)
+            : this(id, attributeIds) {
             Path = path;
         }
 

File Nwazet.Commerce.csproj

View file
  • Ignore whitespace
   </ItemGroup>
   <ItemGroup>
     <Compile Include="Controllers\BundleAdminController.cs" />
+    <Compile Include="Controllers\AttributesAdminController.cs" />
     <Compile Include="Controllers\PromotionAdminController.cs" />
     <Compile Include="Controllers\ProductAdminController.cs" />
     <Compile Include="Controllers\ShippingAdminController.cs" />
     <Compile Include="Controllers\ShoppingCartController.cs" />
     <Compile Include="Drivers\BundlePartDriver.cs" />
+    <Compile Include="Drivers\ProductAttributesPartDriver.cs" />
+    <Compile Include="Drivers\ProductAttributePartDriver.cs" />
     <Compile Include="Drivers\DiscountPartDriver.cs" />
     <Compile Include="Drivers\SizeBasedShippingMethodPartDriver.cs" />
     <Compile Include="Drivers\GoogleCheckoutSettingsPartDriver.cs" />
     <Compile Include="Drivers\WeightBasedShippingMethodPartDriver.cs" />
     <Compile Include="Filters\ReferrerFilter.cs" />
     <Compile Include="Handlers\BundlePartHandler.cs" />
+    <Compile Include="Handlers\AttributePartHandler.cs" />
+    <Compile Include="Handlers\AttributesPartHandler.cs" />
     <Compile Include="Handlers\DiscountPartHandler.cs" />
     <Compile Include="Handlers\SizeBasedShippingMethodPartHandler.cs" />
     <Compile Include="Handlers\WeightBasedShippingMethodPartHandler.cs" />
     <Compile Include="Handlers\GoogleCheckoutSettingsPartHandler.cs" />
     <Compile Include="Handlers\ProductPartHandler.cs" />
+    <Compile Include="Menus\AttributeAdminMenu.cs" />
     <Compile Include="Menus\PromotionAdminMenu.cs" />
     <Compile Include="Menus\ProductAdminMenu.cs" />
     <Compile Include="Menus\ShippingAdminMenu.cs" />
     <Compile Include="Migrations\BundleMigrations.cs" />
     <Compile Include="Migrations\CommerceMigrations.cs" />
+    <Compile Include="Migrations\AttributesMigrations.cs" />
     <Compile Include="Migrations\DiscountMigrations.cs" />
     <Compile Include="Migrations\ShippingMigrations.cs" />
     <Compile Include="Migrations\GoogleCheckoutMigrations.cs" />
+    <Compile Include="Models\ProductAttributesPart.cs" />
+    <Compile Include="Models\ProductAttributesPartRecord.cs" />
+    <Compile Include="Models\ProductAttributePart.cs" />
+    <Compile Include="Models\ProductAttributePartRecord.cs" />
     <Compile Include="Models\BundlePart.cs" />
     <Compile Include="Models\BundlePartRecord.cs" />
     <Compile Include="Models\BundleProductsRecord.cs" />
     <Compile Include="Services\BundleService.cs" />
     <Compile Include="Services\Discount.cs" />
     <Compile Include="Services\DiscountPriceProvider.cs" />
+    <Compile Include="Services\IProductAttributesDriver.cs" />
     <Compile Include="Services\IPriceProvider.cs" />
     <Compile Include="Services\IPriceService.cs" />
     <Compile Include="Services\IPromotion.cs" />
     <Compile Include="Services\WeightBasedShippingMethodProvider.cs" />
     <Compile Include="Tokens\ITokenProvider.cs" />
     <Compile Include="Tokens\ProductTokens.cs" />
+    <Compile Include="ViewModels\AttributesIndexViewModel.cs" />
     <Compile Include="ViewModels\BundleViewModel.cs" />
     <Compile Include="ViewModels\DiscountEditorViewModel.cs" />
+    <Compile Include="ViewModels\ProductAttributesPartEditViewModel.cs" />
     <Compile Include="ViewModels\PromotionIndexViewModel.cs" />
     <Compile Include="ViewModels\ShippingMethodIndexViewModel.cs" />
     <Compile Include="ViewModels\UpdateShoppingCartItemViewModel.cs" />
     <Content Include="Views\ShoppingCartWidget.cshtml" />
     <Content Include="Views\TitleWithLink.cshtml" />
   </ItemGroup>
-  <ItemGroup />
+  <ItemGroup>
+    <None Include="Views\AttributesAdmin\Index.cshtml" />
+    <Content Include="Views\EditorTemplates\Parts\ProductAttribute.cshtml" />
+    <Content Include="Views\EditorTemplates\Parts\ProductAttributes.cshtml" />
+    <None Include="Views\Parts\ProductAttributes.cshtml" />
+  </ItemGroup>
   <PropertyGroup>
     <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">10.0</VisualStudioVersion>
     <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>

File Services/Discount.cs

View file
  • Ignore whitespace
             var comment = DiscountPart.Comment; // TODO: tokenize this
             var percent = DiscountPart.DiscountPercent;
             if (percent != null) {
-                return new ShoppingCartQuantityProduct(quantityProduct.Quantity, quantityProduct.Product) {
+                return new ShoppingCartQuantityProduct(quantityProduct.Quantity, quantityProduct.Product, quantityProduct.AttributeIdsToValues) {
                     Comment = comment,
                     Price = Math.Round(quantityProduct.Price * (1 - ((double)percent / 100)), 2)
                 };
             }
             var discount = DiscountPart.Discount;
             if (discount != null) {
-                return new ShoppingCartQuantityProduct(quantityProduct.Quantity, quantityProduct.Product) {
+                return new ShoppingCartQuantityProduct(quantityProduct.Quantity, quantityProduct.Product, quantityProduct.AttributeIdsToValues) {
                     Comment = comment,
                     Price = Math.Round(Math.Max(0, quantityProduct.Price - (double)discount), 2)
                 };

File Services/IProductAttributesDriver.cs

View file
  • Ignore whitespace
+using System.Collections.Generic;
+using Orchard;
+using Orchard.ContentManagement;
+
+namespace Nwazet.Commerce.Services {
+    public interface IProductAttributesDriver : IDependency {
+        dynamic GetAttributeDisplayShape(IContent product, dynamic shapeHelper);
+        bool ValidateAttributes(IContent product, IDictionary<int, string> attributeIdsToValues);
+    }
+}

File Services/PriceService.cs

View file
  • Ignore whitespace
                 .SelectMany(pp => pp.GetModifiedPrices(productQuantity, shoppingCartQuantities))
                 .ToList();
             if (!modifiedPrices.Any()) return productQuantity;
-            var result = new ShoppingCartQuantityProduct(productQuantity.Quantity, productQuantity.Product);
+            var result = new ShoppingCartQuantityProduct(productQuantity.Quantity, productQuantity.Product, productQuantity.AttributeIdsToValues);
             var minPrice = modifiedPrices.Min(mp => mp.Price);
             result.Price = minPrice;
             var lowestPrice = modifiedPrices.FirstOrDefault(mp => Math.Abs(mp.Price - minPrice) < Epsilon);

File ViewModels/AttributesIndexViewModel.cs

View file
  • Ignore whitespace
+using System.Collections.Generic;
+using Nwazet.Commerce.Models;
+
+namespace Nwazet.Commerce.ViewModels {
+    public class AttributesIndexViewModel {
+        public IEnumerable<ProductAttributePart> Attributes { get; set; }
+        public dynamic Pager { get; set; }
+    }
+}

File ViewModels/ProductAttributesPartEditViewModel.cs

View file
  • Ignore whitespace
+using System.Collections.Generic;
+using Nwazet.Commerce.Models;
+using Orchard.ContentManagement;
+
+namespace Nwazet.Commerce.ViewModels {
+    public class ProductAttributesPartEditViewModel {
+        public IEnumerable<IContent> Attributes { get; set; }
+        public ProductAttributesPart Part { get; set; }
+        public string Prefix { get; set; }
+    }
+}

File ViewModels/UpdateShoppingCartItemViewModel.cs

View file
  • Ignore whitespace
-namespace Nwazet.Commerce.ViewModels
+using System.Collections.Generic;
+
+namespace Nwazet.Commerce.ViewModels
 {
     public class UpdateShoppingCartItemViewModel
     {
         public int ProductId { get; set; }
         public int Quantity { get; set; }
         public bool IsRemoved { get; set; }
+        public IDictionary<int, string> AttributeIdsToValues { get; set; }
     }
 }

File Views/AttributesAdmin/Index.cshtml

View file
  • Ignore whitespace
+@using Orchard.Utility.Extensions
+@model Nwazet.Commerce.ViewModels.AttributesIndexViewModel
+@{
+	var pageSizes = new List<int?>() { 10, 50, 100 };
+	var defaultPageSize = WorkContext.CurrentSite.PageSize;
+	if(!pageSizes.Contains(defaultPageSize)) { 
+		pageSizes.Add(defaultPageSize);
+	}
+    var returnUrl = ViewContext.RequestContext.HttpContext.Request.ToUrlString();
+}
+
+<h1>@Html.TitleForPage(T("Manage Attributes").Text) </h1>
+@using (Html.BeginFormAntiForgeryPost()) {
+    @Html.ValidationSummary()
+    <div class="manage">
+        @Html.ActionLink(
+            T("Add a new product attribute").Text,
+            "Create", "Admin",
+            new {
+                    Area = "Contents",
+                    Id = "ProductAttribute",
+                    ReturnUrl = returnUrl
+            },
+            new { @class = "button primaryAction" })
+    </div>
+
+    <fieldset>		
+        <table class="items">
+            <thead>
+                <tr>
+                    <th scope="col">@T("Name")</th>
+                    <th scope="col" class="actions">&nbsp;</th>
+                </tr>
+            </thead>
+            @foreach (var attribute in Model.Attributes) { 
+                <tr>
+                    <td>
+                        @Html.ItemEditLinkWithReturnUrl(Html.ItemDisplayText(attribute.ContentItem).ToHtmlString(), attribute.ContentItem) 
+                    </td>
+                    <td>
+                        @Html.Link(T("Delete").Text, Url.ItemRemoveUrl(attribute.ContentItem, new {returnUrl}), new {itemprop = "RemoveUrl UnsafeUrl"})
+                    </td>
+                </tr>
+            }
+        </table>
+	@Display(Model.Pager)
+    </fieldset>
+}

File Views/EditorTemplates/Parts/ProductAttribute.cshtml

View file
  • Ignore whitespace
+@model Nwazet.Commerce.Models.ProductAttributePart
+<fieldset>
+    <label class="sub" for="@Html.Id("AttributeValues")">@T("Values")</label><br />
+    @Html.TextArea("AttributeValues", String.Join("\r\n", Model.AttributeValues), 10, 80, new {})
+    <div class="hint">@T("Enter attributes values, one per line. The first one is the default.")</div>
+</fieldset>

File Views/EditorTemplates/Parts/ProductAttributes.cshtml

View file
  • Ignore whitespace
+@using System.Globalization
+@using Orchard.ContentManagement
+@using Orchard.Core.Title.Models
+@model Nwazet.Commerce.ViewModels.ProductAttributesPartEditViewModel
+@{
+    var currentAttributeIds = Model.Part.AttributeIds;
+}
+<fieldset>
+    <legend>@T("Attributes")</legend>
+    @foreach(var attribute in Model.Attributes) {
+        var attributeName = attribute.As<TitlePart>().Title;
+        var idString = attribute.Id.ToString(CultureInfo.InvariantCulture);
+        <input type="checkbox" name="@Html.Name(idString)"@(currentAttributeIds.Contains(attribute.Id) ? " checked=\"checked\"" : "") id="@Html.Id(idString)" value="@attribute.Id"/>
+        <label class="forcheckbox" for="@Html.Id(idString)">@attributeName</label>
+    }
+    <div class="hint">@T("Please select the attributes that apply to this product. Customers will need to choose a value for each active attribute when adding the product to their shopping cart.")</div>
+</fieldset>

File Views/GoogleCheckout.cshtml

View file
  • Ignore whitespace
     CartItems
         Product
         Title
+        ProductAttributes
         Sku
         ProductImage
         IsDigital
 <form method="POST" action="@checkoutUrl"@{if (analyticsOn) {<text>  onsubmit="setUrchinInputCode(pageTracker);"</text>}}>
     @foreach (var product in Model.CartItems) {
         i++;
-        <input type="hidden" name="@("item_name_" + i)" value="@product.Title"/>
+        var title = product.Title
+            + (product.ProductAttributes == null
+            ? "" : " (" + string.Join(", ", product.ProductAttributes.Values) + ")");
+        <input type="hidden" name="@("item_name_" + i)" value="@title"/>
         <input type="hidden" name="@("item_id_" + i)" value="@product.Sku"/>
         <input type="hidden" name="@("item_weight_" + i)" value="@product.Weight"/> 
         <input type="hidden" name="@("item_weight_unit_" + i)" value="@Model.WeightUnit"/> 
-        <input type="hidden" name="@("item_description_" + i)" value="@product.Title"/>
+        <input type="hidden" name="@("item_description_" + i)" value="@title"/>
         <input type="hidden" name="@("item_price_" + i)" value="@product.DiscountedPrice"/>
         <input type="hidden" name="@("item_currency_" + i)" value="@Model.Currency"/>
         <input type="hidden" name="@("item_quantity_" + i)" value="@product.Quantity"/>

File Views/Parts/Product.AddButton.cshtml

View file
  • Ignore whitespace
 }
 
 @using (Html.BeginFormAntiForgeryPost(Url.Action("Add", "ShoppingCart", new { area = "Nwazet.Commerce", id = productId}), FormMethod.Post, new {@class = "addtocart" })) {
+    if (Model.ProductAttributes != null) {
+        foreach (var productAttributeShape in Model.ProductAttributes) {
+            @Display(productAttributeShape)
+        }
+    }
     <input name="quantity" value="1" class="addtocart-quantity" type="number" />
     <button type="submit" class="addtocart-button">@T("Add to cart")</button>
 }

File Views/Parts/ProductAttributes.cshtml

View file
  • Ignore whitespace
+@using Nwazet.Commerce.Models
+@{
+    var attributeFieldName = Html.Name("productattributes");
+    var index = 0;
+}
+@if (Model.ProductAttributes != null && Model.ProductAttributes.Length != 0) {
+    foreach (ProductAttributePart attribute in Model.ProductAttributes) {
+        <input type="hidden" name="@(attributeFieldName)[@(index)].Key" value="@attribute.Id"/>
+        <select name="@(attributeFieldName)[@(index)].Value" class="product-attribute">
+            @foreach (var val in attribute.AttributeValues) {
+                <option>@val</option>
+            }
+        </select>
+        index++;
+    }
+}
+else {
+    <input type="hidden" name="@(attributeFieldName)[0].Key" value="-1"/>
+    <input type="hidden" name="@(attributeFieldName)[0].Value" value="__none__" />
+}

File Views/ShoppingCart.Summary.cshtml

View file
  • Ignore whitespace
         <ul>
             @for (var i = 0; i < items.Count; i++) {
                 var item = items[i];
-                var product = (IProduct) item.Product;
+                var propertyNamePrefix = string.Format("items[{0}]", i);
+                var product = (IProduct)item.Product;
                 var contentItem = (IContent) item.Product;
-                var title = item.Title;
+                var title = item.Title
+                    + (item.ProductAttributes == null 
+                    ? "" : " (" + string.Join(", ", item.ProductAttributes.Values) + ")");
                 string imageUrl = (item.ProductImage != null ? item.ProductImage.Url : null);
                 var quantity = (int)item.Quantity;
                 var unitPrice = (double)item.DiscountedPrice;
                     }
                     <span class="action"><a class="delete" href="#">@T("Remove")</a></span>
                     <span class="price">
-                        <input name="@string.Format("items[{0}].ProductId", i)" type="hidden" value="@product.Id" />
+                        @if (item.ProductAttributes != null) {
+                            var attrIndex = 0;
+                            var attributeNamePrefix = propertyNamePrefix + string.Format(".AttributeIdsToValues[{0}]", attrIndex);
+                            foreach (KeyValuePair<int, string> attribute in item.ProductAttributes) {
+                                <input type="hidden" name="@(attributeNamePrefix).Key" value="@attribute.Key"/>
+                                <input type="hidden" name="@(attributeNamePrefix).Value" value="@attribute.Value" />
+                                attrIndex++;
+                            }
+                        }
+                        <input name="@(propertyNamePrefix + ".ProductId")" type="hidden" value="@product.Id" />
                         <span class="numeric">
-                            <input name="@string.Format("items[{0}].Quantity", i)" type="number" class="quantity" value="@quantity" />
+                            <input name="@(propertyNamePrefix + ".Quantity")" type="number" class="quantity" value="@quantity" />
                         </span>
                         <span class="numeric">@totalPrice.ToString("c")</span>
                     </span>

File Views/ShoppingCart.cshtml

View file
  • Ignore whitespace
                 <tbody>
                     @for (var i = 0; i < items.Count; i++) {
                         var item = items[i];
-                        var product = (IProduct) item.Product;
+                        var propertyNamePrefix = string.Format("items[{0}]", i);
+                        var product = (IProduct)item.Product;
                         var contentItem = (IContent) item.Product;
-                        var title = item.Title;
+                        var title = item.Title
+                            + (item.ProductAttributes == null
+                            ? "" : " (" + string.Join(", ", item.ProductAttributes.Values) + ")");
                         string imageUrl = (item.ProductImage != null ? item.ProductImage.Url : null);
                         var quantity = (int)item.Quantity;
                         var unitPrice = (double)item.DiscountedPrice;
                             }
                             <td class="numeric">@unitPrice.ToString("c")</td>
                             <td class="numeric">
-                                <input name="@string.Format("items[{0}].ProductId", i)" type="hidden" value="@product.Id" />
-                                <input name="@string.Format("items[{0}].Quantity", i)" type="number" class="quantity" value="@quantity" />
+                                @if (item.ProductAttributes != null) {
+                                    var attrIndex = 0;
+                                    var attributeNamePrefix = propertyNamePrefix + string.Format(".AttributeIdsToValues[{0}]", attrIndex);
+                                    foreach (KeyValuePair<int, string> attribute in item.ProductAttributes) {
+                                        <input type="hidden" name="@(attributeNamePrefix).Key" value="@attribute.Key"/>
+                                        <input type="hidden" name="@(attributeNamePrefix).Value" value="@attribute.Value" />
+                                        attrIndex++;
+                                    }
+                                }
+                                <input name="@(propertyNamePrefix + ".ProductId")" type="hidden" value="@product.Id" />
+                                <input name="@(propertyNamePrefix + ".Quantity")" type="number" class="quantity" value="@quantity" />
                             </td>
                             <td class="numeric">@totalPrice.ToString("c")</td>
                             <td class="action"><a class="delete" href="#">@T("Remove")</a></td>

File placement.info

View file
  • Ignore whitespace
   <Place Parts_Bundle="Content:3.2"/>
   <Place Parts_Bundle_Edit="Content:3.2"/>
   <Place Parts_Discount_Edit="Content:2"/>
+  <Place Parts_ProductAttribute_Edit="Content:2"/>
+  <Place Parts_ProductAttributes_Edit="Content:3.3"/>
   <Match DisplayType="SummaryAdmin">
     <Place Parts_Product="Content:2;Alternate=ProductSummaryAdmin"/>
     <Place Parts_Product_AddButton="-"/>
     <Place Parts_GoogleCheckout="-"/>
     <Place Parts_Bundle="Content:3;Alternate=BundleSummaryAdmin"/>
     <Place Fields_MediaPicker="Content:1;Alternate=MediaPickerProductImageSummaryAdmin"/>
+    <Place Parts_ProductAttributes="-"/>
   </Match>
   <Match DisplayType="Thumbnail">
     <Place Fields_MediaPicker="Content:1;Alternate=MediaPickerProductImageThumbnail"/>
     <Place Parts_Product="Content:3"/>
     <Place Parts_Product_AddButton="-"/>
     <Place Parts_Common_Body_Summary="-"/>
+    <Place Parts_ProductAttributes="-"/>
   </Match>
 </Placement>