I'm currently in a multi site EPiServer project where a lot of things needs to be configured per site. Some content types, properties etc should only be available for certain sites. Luckily EPiServer is quite extensible which makes it pretty easy to accomplish this.
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.
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; }
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)
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 { ... }
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>(); } }
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>(); } }