nopCommerce SuperAdmin and Access Control

The nopCommerce access control is fantastic and it achieves its purpose fully. If for example, me as the ultimate administrator doesn’t want the Staff users to see my PalPal configuration settings, I can simply disable the access from the list and they will get an access denied message when they try that link. However, there are cases where us as a consultant / developer we want to refrain the users (owner or staff) from seeing some of the available features for various reasons – maybe you didn’t sell that to them, maybe that’s not applicable to their business (the simpler the application the better). It could be sometimes confusing being a business owner but got denied access to part of your website. In addition, in order to handle such “exceptions” you need good documentations and overtime it just increases your maintenance costs. Hence I am adding a SuperAdmin property to the Customer class and also a custom configuration setting in web.config to achieve the hiding of some features. The reason for adding this into the web.config is to help ourselves remember what we’ve done in the future.

First in ~/Libraries/Nop.BusinessLogic/Customer/Customer.cs I am adding a public property called IsSuperAdmin.

/// <summary>
/// Gets a boolean indicating if this custom is a Super Admin (CustomerRoleId == 1)
/// </summary>
public bool IsSuperAdmin
{
    get
    {
        if (this.CustomerRoles.SingleOrDefault(t => t.CustomerRoleId == 1) != null)
        {
            return true;
        }
        else
        {
            return false;
        }
    }
}

It’s defining the CustomerRold with ID = 1 as the “Super Admin” role, which means that you will need to make sure in the database that the one with ID = 1 is indeed the customer role you want to have the ultimate access. Ideally there should be a column in the Customer table for this flag, but I think this is the easiest way without modifying the data structure.

Next I will add a new section to NopConfig in web.config, which I am calling AdminSettings (Of course you could name it as whatever makes most sense to you).

<NopConfig>
    …        
<
AdminSettings>
<AdminSetting key="CategorySEOTabVisibility" value="false" />
</AdminSettings>
</NopConfig>

The NopConfig class handles the custom config section <NopConfig> in web.config. So I am adding a property called AdminSettings in ~/Libraries/BusinessLogic/Configuration/NopConfig.cs to store this new </AdminSettings> config section.

private static Dictionary<string, string> _adminSettings = null;
public static Dictionary<string, string> AdminSettings
{
    get { return _adminSettings; }
}

The ideal place to initialize this property is in the Create method. If you follow the code path, you can see that these config settings are initialized in global.asax.

public object Create(object parent, object configContext, XmlNode section)
{ // Other code XmlNode adminSettingNodes = section.SelectSingleNode("AdminSettings"); if (adminSettingNodes != null) { _adminSettings = new Dictionary<string, string>(); foreach (XmlNode pdNode in adminSettingNodes.ChildNodes) { if (string.Compare(pdNode.Name.Trim(), "adminsetting", true) == 0) { _adminSettings.Add((string)pdNode.Attributes["key"].Value, (string)pdNode.Attributes["value"].Value); } } } return null; }

 

With all of these in place, we can start to hide out some of the components in the admin pages. For example, if we wish to hide the SEO tab in the category editing page. We will use the following line in Page_Load of the CategoryDetailsControl class (~/Administration/Modules/CategoryDetails.ascx.cs)

pnlCategorySEO.Enabled = pnlCategorySEO.Visible = ((!NopConfig.AdminSettings.ContainsKey("CategorySEOTabVisibility")) || Boolean.Parse(NopConfig.AdminSettings["CategorySEOTabVisibility"]) || NopContext.Current.User.IsSuperAdmin);

For another instance, if we wish to not hide the “Customer Enter Price” checkbox in product variant editing page because the business just will never allow customers to enter their own price, we can do the following in ~/Administration/Modules/ProductVariantInfo.ascx.

<% if (!NopSolutions.NopCommerce.BusinessLogic.Configuration.NopConfig.AdminSettings.ContainsKey("ProductVariantCustomerEnterPriceCheckboxVisibility") ||
           Boolean.Parse(NopSolutions.NopCommerce.BusinessLogic.Configuration.NopConfig.AdminSettings["ProductVariantCustomerEnterPriceCheckboxVisibility"]) ||
           NopContext.Current.User.IsSuperAdmin)
       {%>
    <tr>
        <td class="adminTitle">
            <nopcommerce:tooltiplabel runat="server" id="lblCallForPrice" text="<% $NopResources:Admin.ProductVariantInfo.CallForPrice %>"
                tooltip="<% $NopResources:Admin.ProductVariantInfo.CallForPrice.Tooltip %>" tooltipimage="~/Administration/Common/ico-help.gif" />
        </td>
        <td class="adminData">
            <asp:CheckBox ID="cbCallForPrice" runat="server" Checked="False"></asp:CheckBox>
        </td>
    </tr>
<%} %>

 

Hope this is useful! Feedbacks are welcome and maybe better ways to achieve such!

Use ASP.NET 4 URL Routing in nopCommerce 2

The 9 steps in the previous post should have gotten the re-writing working, but there are also more that needs to be refined. The biggest change in the previous post was the use of SEName to retrieve category as opposed to CategoryId. This creates an additional manual step as when you insert a new category you need to be sure to also input “Search engine friendly page name” in the SEO tab, otherwise that rewriting won’t work.

The original rewrite is already using a good method to generate nice SEO friendly names, why don’t we leverage that and always also insert the SEName when a new category is added? Only one thing to do:

In NopCommerceStore.Administration.Modules.CategoryAdd.ascx.cs, which contains the 4 tabs used in creating a new category, before save to database happens we get the Name field in the Category Info tab, call the helper function to generate the SEO friendly name, and fill in the SEName textbox in the SEO tab. I have to say, FindControl is so ASP.NET WebForm, haha…

protected Category Save()
{
    SimpleTextBox nameTextBox = ((SimpleTextBox)ctrlCategoryInfo.FindControl("txtName"));
    if (nameTextBox != null)
    {
        string catName = nameTextBox.Text;
        TextBox seNameTextBox = ((TextBox)ctrlCategorySEO.FindControl("txtSEName"));
        if (seNameTextBox != null)
        {
            string seName = seNameTextBox.Text;
            if (string.IsNullOrWhiteSpace(seName))
            {
                seNameTextBox.Text = NopSolutions.NopCommerce.BusinessLogic.SEO.SEOHelper.GetSEName(catName);
            }
        }
    }

Further to this topic I guess what we could do is to ensure that the SEName column is always unique. That’s a bigger change to the Database and the Entity context.

Use ASP.NET 4 URL Routing in nopCommerce

Note: in this discussion the nopCommerce version is 1.90 and it addresses only the product category page, not the product details page. In 1.90 the old Manager classes were re-named to Service classes, but beside such naming change, most of the inside logics remain intact (as far as this topic is concerned).

nopCommerce by default uses the UrlRewriting.NET solution and it actually works wonderfully. The only thing that I don’t like about was having to include the CategoryID in the URL. For instance, if URLRewriting is turned on (you can do so in Admin>Global Settings>SEO), you get ~/category/{id}-{sename}.aspx as your URL (Or whatever format you prefer, just that you can’t avoid having the {id} in the URL, which seems annoying to me.)

We can, however, construct a nice, SEO-friendly URL step by step:

1. Add the UrlRoutingModule to web.config

2. Disable the UrlRewriteModule from web.config

3. Make sure runAllManagedModulesForAllRequests is set to true

Here’s the <system.webServer> node in web.config highlighting steps 1 – 3

<system.webServer>
    <validation validateIntegratedModeConfiguration="false" />
    <modules runAllManagedModulesForAllRequests="true">
        <remove name="NopCommerceFilter" />
        <remove name="UrlRewriteModule" />
        <add name="MembershipHttpModule" preCondition="managedHandler" type="NopSolutions.NopCommerce.BusinessLogic.Profile.MembershipHttpModule, Nop.BusinessLogic" />            
        <!--<add name="UrlRewriteModule" preCondition="managedHandler" type="UrlRewritingNet.Web.UrlRewriteModule, UrlRewritingNet.UrlRewriter" />-->
        <add name="BlacklistHttpModule" preCondition="managedHandler" type="NopSolutions.NopCommerce.BusinessLogic.Security.BlacklistHttpModule, Nop.BusinessLogic" />
        <add name="UrlRoutingModule" type="System.Web.Routing.UrlRoutingModule, System.Web.Routing, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
    </modules>
    <handlers>
        <add name="ChartImageHandler" preCondition="integratedMode" verb="GET,HEAD,POST" path="ChartImg.axd" type="System.Web.UI.DataVisualization.Charting.ChartHttpHandler, System.Web.DataVisualization, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35" />
        <add name="PricelistHandler" verb="*" path="pricelist.csv" preCondition="integratedMode" type="NopSolutions.NopCommerce.BusinessLogic.ExportImport.PricelistHandler, Nop.BusinessLogic" />
        <add name="UrlRoutingHandler" preCondition="integratedMode" verb="*" path="UrlRouting.axd" type="System.Web.HttpForbiddenHandler,System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
    </handlers>
</system.webServer>

4. Update how the category URL is constructed in  NopSolutions.NopCommerce.BusinessLogic.SEO.GetCategoryUrl(Category category)

string url2 = "{0}shop/{1}";
stringurl = string.Format(url2, CommonHelper.GetStoreLocation(), seName);
returnurl.ToLowerInvariant();

which means Enabling URL Rewriting or not will yield the same result (We do want to enforce the SEO-friendly URL). Note that the CategoryId is no longer in the URL. shop in the example above is just the hardcoded piece of the path so it could be anything.

5.  Make sure that the web app has reference to System.Web.Routing assembly

6. Add the routing map to Global.asax so now we can have a URL that looks something like  http://yourstore.com/shop/electronics and the application will know to redirect it to Category.aspx

void RegisterRoutes(System.Web.Routing.RouteCollection routes)
{
    routes.MapPageRoute(
         "category-browse",       // Route name 
         "shop/{SEName}",         // URL  
         "~/Category.aspx" // Web Forms page   
    );
}
void Application_Start(object sender, EventArgs e)
{
    RegisterRoutes(System.Web.Routing.RouteTable.Routes);    …

7. Add a method called GetCategoryBySEName(string seName) to ICategoryService & CategoryService. We need this because the URL no longer contains the CategoryId so we will need to retrieve the Category using SEName.

/// <summary>
/// Gets a category
/// </summary>
/// <param name="seName">Search engine friendly name of the category</param>
/// <returns>Category</returns>
public Category GetCategoryBySEName(string seName)
{
    if (string.IsNullOrWhiteSpace(seName))
    {
        return null;
    }

    string key = string.Format(CATEGORIES_BY_SENAME, seName);
    object obj2 = _cacheManager.Get(key);
    if (this.CategoriesCacheEnabled && (obj2 != null))
    {
        return (Category)obj2;
    }

    bool showHidden = NopContext.Current.IsAdmin;

    var query = from c in _context.Categories
                where c.SEName == seName
                select c;
    Category category = query.FirstOrDefault();

    //filter by access control list (public store)
    if (category != null && !showHidden && IsCategoryAccessDenied(category))
    {
        category = null;
    }
    if (this.CategoriesCacheEnabled)
    {
        _cacheManager.Add(key, category);
    }

    return category;
}

8. Update in Category.aspx to retrieve the category using the nice URL. ~/shop/electronics

The first line of the CreateChildControlTree() method it calls CategoryService’s GetCategoryById. Now we need to use GetCategoryBySEName that we just added above.

private void CreateChildControlsTree()
{
    category = this.CategoryService.GetCategoryBySEName(this.SEName);

To do that, we need to change the SEName property to get the value from RouteData, not query string.

public string SEName
{
    get
    {
        return Page.RouteData.Values["SEName"] as string;
    }
}

9. Update the same in your product templates as well. If there is no SEName property in the product templates, add the one like in Step 8. In the method BindData() is where it references a lot to the category, so I’ve created a private variable to hold the Category object that I will retrieve using SEName,

public partial class ProductsInGrid: BaseNopFrontendUserControl
{
    private Category _category = null;
protected void BindData()
{
    _category = this.CategoryService.GetCategoryBySEName(this.SEName);

After you complete all 9 steps here, you should be all set and have a very nice SEO-friendly URL for your nopCommerce store.

Consume RESTful Web Services in C#

The acronym REST stands for Representational State Transfer, this basically means that each unique URL is a representation of some object. It embraces a stateless client-server architecture in which the web services are viewed as resources and can be identified by their URLs. It’s different from the more traditional SOAP. Not gonna go into the details of the differences between the two as it will be endless. This link from AjaxPatterns has a pretty in-depth explanation of RESTful services.

http://ajaxpatterns.org/RESTful_Service

There are many ways to consume a RESTful service in C#. Since all it needs is the URL we can create an Http Web Request and then process the response. Another way is to use the more advanced WCF, which I’ve came across this link here:

http://blogs.msdn.com/b/pedram/archive/2008/04/21/how-to-consume-rest-services-with-wcf.aspx

It’s very good as it uses WCF to do the serialization and it eliminates the need to do manual transformation. But as always, you don’t get nothing for free. In this case it’s the same as all other situations when you let the framework do your dirty laundry – you lost flexibility. In addition, it might be hard for someone viewing your code to fully understand what’s going on right away because some stuff is in the web.config, some in code, and some in attributes.

A lot of the publicly available RESTful web services, such as Google Map API, Flicker’s API, Bing platform’s services, support both JSON and XML format. In the JavaScript world I prefer JSON, but when it comes to C# I still find XML much easier to work with for the simple fact that LINQ to XML is right at your fingertip.

Therefore, I find that using LINQ to XML and XPath transformation is pretty easy to consume a RESTful services and extract the information that you need. I am using Google Map API as an example here:

   1: XElement root = XElement.Load("http://maps.googleapis.com/maps/api/geocode/xml?sensor=false&address=Toronto");

   2: if (decimal.TryParse(root.XPathSelectElement("//result//geometry//location//lat").Value, out latitude) && 

   3:     decimal.TryParse(root.XPathSelectElement("//result//geometry//location//lat").Value, out longitude))

   4: {

   5:     result.Add(new BusinessObjects.Location

   6:     {

   7:         Coordinate = new BusinessObjects.LatLng { Latitude = latitude, Longitude = longitude },

   8:         FormattedAddress = root.XPathSelectElement("//result//geometry//location//lat").Value,

   9:     });

  10: }

As you can see, only a few lines of code. I am calling Google Map’s geocoding service and looking for Toronto. As soon as I have loaded the response XML into XElement I used XPath to get the latitude and longitude, as well as the formatted address in order to construct my own Location object, which might then contain other business logics and validation requirements specific to my application.

Well, that’s it. Here’s the XML response of the link above:

<?xml version="1.0" encoding="utf-8" ?>
<GeocodeResponse>
    <status>OK</status>
    <result>
        <type>locality</type>
        <type>political</type>
        <formatted_address>Toronto, ON, Canada</formatted_address>
        <address_component>
            <long_name>Toronto</long_name>
            <short_name>Toronto</short_name>
            <type>locality</type>
            <type>political</type>
        </address_component>
        <address_component>
            <long_name>Toronto Division</long_name>
            <short_name>Toronto Division</short_name>
            <type>administrative_area_level_2</type>
            <type>political</type>
        </address_component>
        <address_component>
            <long_name>Ontario</long_name>
            <short_name>ON</short_name>
            <type>administrative_area_level_1</type>
            <type>political</type>
        </address_component>
        <address_component>
            <long_name>Canada</long_name>
            <short_name>CA</short_name>
            <type>country</type>
            <type>political</type>
        </address_component>
        <geometry>
            <location>
                <lat>43.6525000</lat>
                <lng>-79.3816667</lng>
            </location>
            <location_type>APPROXIMATE</location_type>
            <viewport>
                <southwest>
                    <lat>43.5231891</lat>
                    <lng>-79.6377855</lng>
                </southwest>
                <northeast>
                    <lat>43.7815330</lat>
                    <lng>-79.1255479</lng>
                </northeast>
            </viewport>
            <bounds>
                <southwest>
                    <lat>43.4582970</lat>
                    <lng>-79.6392190</lng>
                </southwest>
                <northeast>
                    <lat>43.8554580</lat>
                    <lng>-79.0024810</lng>
                </northeast>
            </bounds>
        </geometry>
    </result>
</GeocodeResponse>
Follow

Get every new post delivered to your Inbox.