Controlling content type and property availability in multi site solutions

Mattias Olsson 2015-10-08 02:28:26

Scenario

We have a couple of components like Vippy for video asset management and Fotoweb for image asset management that should only be available on a few selected sites. The other sites should use YouTube and standard EPiServer asset management. For Vippy we have a Video block, a Video archive page, a Video details page and finally a Video selection property on the article page. For Fotoweb we have an Image block and an Image selection property on the article page.

Specification

  • Create settings properties on the start page to enable/disable mentioned features.
  • Create an attribute that implements System.Web.Mvc.IMetadataAware to use on content type classes and properties in code to be able to control availability.
  • Create a custom content type repository to filter the content types per site.

Settings properties

We have the following properties defined on our start page:

[Display(Name = "Enable Vippy")]
public virtual bool EnableVippy { get; set; }

[Display(Name = "Enable Fotoweb")]
public virtual bool EnableFotoweb { get; set; }

 

Custom attribute

To be able to control availability of a content type or a property I created the following attribute:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false)]
public class AvailableAttribute : Attribute, IMetadataAware
{
    public string PropertyName { get; set; }
    public bool Condition { get; set; }

    public AvailableAttribute(string propertyName, bool condition)
    {
        PropertyName = propertyName;
        Condition = condition; // If the property should be true/false.
    }

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
        var startPage = contentLoader.Get<PageData>(SiteDefinition.Current.StartPage);
        bool available = IsConditionFulfilled(startPage);
        metadata.ShowForEdit = available;
        metadata.ShowForDisplay = available

        if (!available)
            metadata.IsReadOnly = true;
    }

    public virtual bool IsConditionFulfilled(PageData startPage)
    {
        var propertyValue = startPage.GetPropertyValue<bool>(PropertyName);
        return propertyValue == Condition;
    }
}

Now, as a first step, we can decorate any property with the new attribute like this:

// Show this only if EnableFotoweb property on the start page is true.
[Available("EnableFotoweb", true)]
[Display(Name = "Thumbnail image")]
public virtual FotowareImage FotowebThumbnailImage { get; set; }

// Show this only if EnableFotoweb property on the start page is false.
[Available("EnableFotoweb", false)]
[Display(Name = "Thumbnail image")]
[UIHint(UIHint.Image)]
public virtual ContentReference StandardThumbnailImage { get; set; }

A good thing that comes with the attribute is I can now have both my properties in the article page view like this and only one of them will be rendered based on the "EnableFotoweb" property value on the start page:

@Html.PropertyFor(m => m.FotowebThumbnailImage)
@Html.PropertyFor(m => m.StandardThumbnailImage)

 

Custom content type repository

To be able to filter content types per site based on the settings on the site start page I created a custom content type repository and implemented my own List method. The content type list is cached by site and the cache is invalidated as soon as you make any changes to the start page:

[ServiceConfiguration(ServiceType = typeof(IContentTypeRepository<ContentType>))]
[ServiceConfiguration(ServiceType = typeof(IContentTypeRepository))]
public class SiteContentTypeRepository : DefaultContentTypeRepository
{
    private readonly IContentLoader _contentLoader;

    public SiteContentTypeRepository(..., IContentLoader contentLoader) : base(...)
    {
        if (contentLoader == null) throw new ArgumentNullException("contentLoader");
        _contentLoader = contentLoader;
    }

    public override IEnumerable List()
    {
        if (SiteDefinition.Current == null 
            || ContentReference.IsNullOrEmpty(SiteDefinition.Current.StartPage))
        {
            return base.List();
        }

        return GetContentTypesFromCache();
    }

    protected virtual IEnumerable GetContentTypesFromCache()
    {
        var cacheKey = GetCacheKey();
        var contentTypes = CacheManager.Get(cacheKey) as IEnumerable<ContentType>;

        if (contentTypes == null)
        {
            var startPage = _contentLoader.Get<StartPage>(SiteDefinition.Current.StartPage);
            contentTypes = ListFilteredContentTypes(startPage);

            CacheManager.Insert(
                cacheKey, 
                contentTypes, 
                new CacheEvictionPolicy(
                    null, 
                    new [] {       
                        DataFactoryCache.PageLanguageCacheKey(
                            SiteDefinition.Current.StartPage, 
                            startPage.LanguageBranch) 
                    }, 
                    null, 
                    Cache.NoSlidingExpiration, 
                    CacheTimeoutType.Sliding)
            );
        }

        return contentTypes;
    }

    protected virtual IEnumerable ListFilteredContentTypes(StartPage startPage)
    {
        var contentTypes = base.List();

        foreach (ContentType contentType in contentTypes)
        {
            var modelType = contentType.ModelType;
            var attr = modelType != null
                ? modelType.GetCustomAttribute()
                : null;

            if (attr == null || attr.IsConditionFulfilled(startPage))
            {
                yield return contentType;
            }
        }
    }

    protected virtual string GetCacheKey()
    {
        return string.Format("ContentTypes-{0}", SiteDefinition.Current.Name);
    }
}

Now, we can decorate an entire content type class with the AvailableAttribute:

[ContentType(DisplayName = "Vippy - Video archive")]
[Available("EnableVippy", true)]
public class VippyVideoArchivePage : PageData
{
    ...
}

 

Wrapping it up

As a last step I created an initializable module to swap out EPiServer default content repository with our own:

[ModuleDependency(typeof(ServiceContainerInitialization))]
[InitializableModule]
public class ConfigurationModule : IConfigurableModule
{
    public void Initialize(InitializationEngine context)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }

    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Container.Configure(ConfigureContainer);
    }

    private static void ConfigureContainer(ConfigurationExpression container)
    {
        container.For<IContentTypeRepository<ContentType>>()
            .Singleton()
            .Use<SiteContentTypeRepository>();
        container.For<IContentTypeRepository>()
            .Singleton()
            .Use<SiteContentTypeRepository>();
    }
}

 

Update (october 19th 2015)

After an improvement suggestion from Per Bjurström, I decided to create my own implementation of the ContentTypeAvailabilityService instead of IContentTypeRepository. However, I was a bit lazy and currently I'm inheriting the default implementation instead of wrapping the abstract class. I'll just take the hit in case of a breaking change. This is what I ended up with:

[ServiceConfiguration(typeof(ContentTypeAvailabilityService), Lifecycle = ServiceInstanceScope.Singleton)]
public class SiteContentTypeAvailabilityService : DefaultContentTypeAvailablilityService
{
    private readonly IContentLoader _contentLoader;
    private readonly SiteDefinitionResolver _siteDefinitionResolver;

    public SiteContentTypeAvailabilityService(..., SiteDefinitionResolver siteDefinitionResolver) 
: base(...) { if (contentLoader == null) throw new ArgumentNullException("contentLoader"); if (siteDefinitionResolver == null) throw new ArgumentNullException("siteDefinitionResolver"); _contentLoader = contentLoader; _siteDefinitionResolver = siteDefinitionResolver; } public override IList<ContentType> ListAvailable(IContent content, bool contentFolder, IPrincipal user) { var baseList = base.ListAvailable(content, contentFolder, user); Filter(baseList, content); return baseList; } protected virtual void Filter(IList<ContentType> contentTypes, IContent content) { StartPage startPage; var siteDefinition = content != null ? _siteDefinitionResolver.GetDefinitionForContent(content.ContentLink, false, false) : SiteDefinition.Current; if (siteDefinition == null || ContentReference.IsNullOrEmpty(siteDefinition.StartPage) || !_contentLoader.TryGet(siteDefinition.StartPage, out startPage)) { return; } for (int i = contentTypes.Count - 1; i >= 0; i--) { var contentType = contentTypes[i]; var modelType = contentType.ModelType; var attr = modelType != null ? modelType.GetCustomAttribute<AvailableAttribute>() : null; if (attr != null && !attr.IsConditionFulfilled(startPage)) { contentTypes.RemoveAt(i); } } } }

The initialization module now looks like this:

[ModuleDependency(typeof(ServiceContainerInitialization))]
[InitializableModule]
public class ConfigurationModule : IConfigurableModule
{
    public void Initialize(InitializationEngine context)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }

    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Container.Configure(ConfigureContainer);
    }

    private static void ConfigureContainer(ConfigurationExpression container)
    {
        container.For<ContentTypeAvailabilityService>()
            .Singleton()
            .Use<SiteContentTypeAvailabilityService>();
    }
}