diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index dcca8c2adbe0..1caa81d80a03 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -219,6 +219,9 @@ @keyframes umbraco-preview-badge--effect {{ [DefaultValue(StaticLoginLogoImage)] public string LoginLogoImage { get; set; } = StaticLoginLogoImage; - + /// + /// Get or sets the model representing the global content version cleanup policy + /// + public ContentVersionCleanupPolicySettings ContentVersionCleanupPolicy { get; set; } = new ContentVersionCleanupPolicySettings(); } } diff --git a/src/Umbraco.Core/Configuration/Models/ContentVersionCleanupPolicySettings.cs b/src/Umbraco.Core/Configuration/Models/ContentVersionCleanupPolicySettings.cs new file mode 100644 index 000000000000..02724197017e --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/ContentVersionCleanupPolicySettings.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models +{ + /// + /// Model representing the global content version cleanup policy + /// + public class ContentVersionCleanupPolicySettings + { + private const bool StaticEnableCleanup = false; + private const int StaticKeepAllVersionsNewerThanDays = 2; + private const int StaticKeepLatestVersionPerDayForDays = 30; + + /// + /// Gets or sets a value indicating whether or not the cleanup job should be executed. + /// + [DefaultValue(StaticEnableCleanup)] + public bool EnableCleanup { get; set; } = StaticEnableCleanup; + + /// + /// Gets or sets the number of days where all historical content versions are kept. + /// + [DefaultValue(StaticKeepAllVersionsNewerThanDays)] + public int KeepAllVersionsNewerThanDays { get; set; } = StaticKeepAllVersionsNewerThanDays; + + /// + /// Gets or sets the number of days where the latest historical content version for that day are kept. + /// + [DefaultValue(StaticKeepLatestVersionPerDayForDays)] + public int KeepLatestVersionPerDayForDays { get; set; } = StaticKeepLatestVersionPerDayForDays; + + } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentTypeSave.cs b/src/Umbraco.Core/Models/ContentEditing/ContentTypeSave.cs index 79f3fba1fd0f..6198f0c6641c 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentTypeSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentTypeSave.cs @@ -29,6 +29,9 @@ protected ContentTypeSave() [DataMember(Name = "allowedContentTypes")] public IEnumerable AllowedContentTypes { get; set; } + [DataMember(Name = "historyCleanup")] + public HistoryCleanupViewModel HistoryCleanup { get; set; } + /// /// Custom validation /// diff --git a/src/Umbraco.Core/Models/ContentEditing/DocumentTypeDisplay.cs b/src/Umbraco.Core/Models/ContentEditing/DocumentTypeDisplay.cs index 7d45c466002a..1fa0895f61e1 100644 --- a/src/Umbraco.Core/Models/ContentEditing/DocumentTypeDisplay.cs +++ b/src/Umbraco.Core/Models/ContentEditing/DocumentTypeDisplay.cs @@ -6,11 +6,9 @@ namespace Umbraco.Cms.Core.Models.ContentEditing [DataContract(Name = "contentType", Namespace = "")] public class DocumentTypeDisplay : ContentTypeCompositionDisplay { - public DocumentTypeDisplay() - { + public DocumentTypeDisplay() => //initialize collections so at least their never null AllowedTemplates = new List(); - } //name, alias, icon, thumb, desc, inherited from the content type @@ -18,8 +16,7 @@ public DocumentTypeDisplay() [DataMember(Name = "allowedTemplates")] public IEnumerable AllowedTemplates { get; set; } - [DataMember(Name = "defaultTemplate")] - public EntityBasic DefaultTemplate { get; set; } + [DataMember(Name = "defaultTemplate")] public EntityBasic DefaultTemplate { get; set; } [DataMember(Name = "allowCultureVariant")] public bool AllowCultureVariant { get; set; } @@ -27,7 +24,8 @@ public DocumentTypeDisplay() [DataMember(Name = "allowSegmentVariant")] public bool AllowSegmentVariant { get; set; } - [DataMember(Name = "apps")] - public IEnumerable ContentApps { get; set; } + [DataMember(Name = "apps")] public IEnumerable ContentApps { get; set; } + + [DataMember(Name = "historyCleanup")] public HistoryCleanupViewModel HistoryCleanup { get; set; } } } diff --git a/src/Umbraco.Core/Models/ContentEditing/HistoryCleanup.cs b/src/Umbraco.Core/Models/ContentEditing/HistoryCleanup.cs new file mode 100644 index 000000000000..5e7b34b0bf0c --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/HistoryCleanup.cs @@ -0,0 +1,16 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models.ContentEditing +{ + [DataContract(Name = "historyCleanup", Namespace = "")] + public class HistoryCleanup + { + [DataMember(Name = "preventCleanup")] public bool PreventCleanup { get; set; } + + [DataMember(Name = "keepAllVersionsNewerThanDays")] + public int? KeepAllVersionsNewerThanDays { get; set; } + + [DataMember(Name = "keepLatestVersionPerDayForDays")] + public int? KeepLatestVersionPerDayForDays { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/HistoryCleanupViewModel.cs b/src/Umbraco.Core/Models/ContentEditing/HistoryCleanupViewModel.cs new file mode 100644 index 000000000000..94423a80606e --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/HistoryCleanupViewModel.cs @@ -0,0 +1,17 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models.ContentEditing +{ + [DataContract(Name = "historyCleanup", Namespace = "")] + public class HistoryCleanupViewModel : HistoryCleanup + { + [DataMember(Name = "globalKeepAllVersionsNewerThanDays")] + public int? GlobalKeepAllVersionsNewerThanDays { get;set; } + + [DataMember(Name = "globalKeepLatestVersionPerDayForDays")] + public int? GlobalKeepLatestVersionPerDayForDays { get; set;} + + [DataMember(Name = "globalEnableCleanup")] + public bool GlobalEnableCleanup { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/ContentType.cs b/src/Umbraco.Core/Models/ContentType.cs index 9a0e1a6854ce..c37e16583767 100644 --- a/src/Umbraco.Core/Models/ContentType.cs +++ b/src/Umbraco.Core/Models/ContentType.cs @@ -2,13 +2,14 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Models { /// - /// Represents the content type that a object is based on + /// Represents the content type that a object is based on /// [Serializable] [DataContract(IsReference = true)] @@ -16,54 +17,50 @@ public class ContentType : ContentTypeCompositionBase, IContentType { public const bool SupportsPublishingConst = true; - private int _defaultTemplate; + + //Custom comparer for enumerable + private static readonly DelegateEqualityComparer> TemplateComparer = new( + (templates, enumerable) => templates.UnsortedSequenceEqual(enumerable), + templates => templates.GetHashCode()); + private IEnumerable _allowedTemplates; + private int _defaultTemplate; + /// - /// Constuctor for creating a ContentType with the parent's id. + /// Constuctor for creating a ContentType with the parent's id. /// /// Only use this for creating ContentTypes at the root (with ParentId -1). /// - public ContentType(IShortStringHelper shortStringHelper, int parentId) : base(shortStringHelper, parentId) - { + public ContentType(IShortStringHelper shortStringHelper, int parentId) : base(shortStringHelper, parentId) => _allowedTemplates = new List(); - } /// - /// Constuctor for creating a ContentType with the parent as an inherited type. + /// Constuctor for creating a ContentType with the parent as an inherited type. /// /// Use this to ensure inheritance from parent. /// /// public ContentType(IShortStringHelper shortStringHelper, IContentType parent, string alias) - : base(shortStringHelper, parent, alias) - { + : base(shortStringHelper, parent, alias) => _allowedTemplates = new List(); - } - - /// - public override ISimpleContentType ToSimple() => new SimpleContentType(this); /// public override bool SupportsPublishing => SupportsPublishingConst; - //Custom comparer for enumerable - private static readonly DelegateEqualityComparer> TemplateComparer = new DelegateEqualityComparer>( - (templates, enumerable) => templates.UnsortedSequenceEqual(enumerable), - templates => templates.GetHashCode()); + /// + public override ISimpleContentType ToSimple() => new SimpleContentType(this); /// - /// Gets or sets the alias of the default Template. - /// TODO: This should be ignored from cloning!!!!!!!!!!!!!! - /// - but to do that we have to implement callback hacks, this needs to be fixed in v8, + /// Gets or sets the alias of the default Template. + /// TODO: This should be ignored from cloning!!!!!!!!!!!!!! + /// - but to do that we have to implement callback hacks, this needs to be fixed in v8, /// we should not store direct entity /// [IgnoreDataMember] - public ITemplate DefaultTemplate - { - get { return AllowedTemplates.FirstOrDefault(x => x != null && x.Id == DefaultTemplateId); } - } + public ITemplate DefaultTemplate => + AllowedTemplates.FirstOrDefault(x => x != null && x.Id == DefaultTemplateId); [DataMember] @@ -74,9 +71,9 @@ public int DefaultTemplateId } /// - /// Gets or Sets a list of Templates which are allowed for the ContentType - /// TODO: This should be ignored from cloning!!!!!!!!!!!!!! - /// - but to do that we have to implement callback hacks, this needs to be fixed in v8, + /// Gets or Sets a list of Templates which are allowed for the ContentType + /// TODO: This should be ignored from cloning!!!!!!!!!!!!!! + /// - but to do that we have to implement callback hacks, this needs to be fixed in v8, /// we should not store direct entity /// [DataMember] @@ -85,41 +82,42 @@ public IEnumerable AllowedTemplates get => _allowedTemplates; set { - SetPropertyValueAndDetectChanges(value, ref _allowedTemplates, nameof(AllowedTemplates), TemplateComparer); + SetPropertyValueAndDetectChanges(value, ref _allowedTemplates, nameof(AllowedTemplates), + TemplateComparer); if (_allowedTemplates.Any(x => x.Id == _defaultTemplate) == false) + { DefaultTemplateId = 0; + } } } + public HistoryCleanup HistoryCleanup { get; set; } + /// - /// Determines if AllowedTemplates contains templateId + /// Determines if AllowedTemplates contains templateId /// /// The template id to check /// True if AllowedTemplates contains the templateId else False - public bool IsAllowedTemplate(int templateId) - { - return AllowedTemplates == null + public bool IsAllowedTemplate(int templateId) => + AllowedTemplates == null ? false : AllowedTemplates.Any(t => t.Id == templateId); - } /// - /// Determines if AllowedTemplates contains templateId + /// Determines if AllowedTemplates contains templateId /// /// The template alias to check /// True if AllowedTemplates contains the templateAlias else False - public bool IsAllowedTemplate(string templateAlias) - { - return AllowedTemplates == null + public bool IsAllowedTemplate(string templateAlias) => + AllowedTemplates == null ? false : AllowedTemplates.Any(t => t.Alias.Equals(templateAlias, StringComparison.InvariantCultureIgnoreCase)); - } /// - /// Sets the default template for the ContentType + /// Sets the default template for the ContentType /// - /// Default + /// Default public void SetDefaultTemplate(ITemplate template) { if (template == null) @@ -138,17 +136,19 @@ public void SetDefaultTemplate(ITemplate template) } /// - /// Removes a template from the list of allowed templates + /// Removes a template from the list of allowed templates /// - /// to remove + /// to remove /// True if template was removed, otherwise False public bool RemoveTemplate(ITemplate template) { if (DefaultTemplateId == template.Id) - DefaultTemplateId = default(int); + { + DefaultTemplateId = default; + } var templates = AllowedTemplates.ToList(); - var remove = templates.FirstOrDefault(x => x.Id == template.Id); + ITemplate remove = templates.FirstOrDefault(x => x.Id == template.Id); var result = templates.Remove(remove); AllowedTemplates = templates; @@ -156,6 +156,7 @@ public bool RemoveTemplate(ITemplate template) } /// - IContentType IContentType.DeepCloneWithResetIdentities(string newAlias) => (IContentType)DeepCloneWithResetIdentities(newAlias); + IContentType IContentType.DeepCloneWithResetIdentities(string newAlias) => + (IContentType)DeepCloneWithResetIdentities(newAlias); } } diff --git a/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs b/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs new file mode 100644 index 000000000000..5e3f319e6a73 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentVersionCleanupPolicySettings.cs @@ -0,0 +1,13 @@ +using System; + +namespace Umbraco.Core.Models +{ + public class ContentVersionCleanupPolicySettings + { + public int ContentTypeId { get; set; } + public int? KeepAllVersionsNewerThanDays { get; set; } + public int? KeepLatestVersionPerDayForDays { get; set; } + public bool PreventCleanup { get; set; } + public DateTime Updated { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs b/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs new file mode 100644 index 000000000000..ce2c771ec6bf --- /dev/null +++ b/src/Umbraco.Core/Models/HistoricContentVersionMeta.cs @@ -0,0 +1,24 @@ +using System; + +namespace Umbraco.Cms.Core.Models +{ + public class HistoricContentVersionMeta + { + public int ContentId { get; } + public int ContentTypeId { get; } + public int VersionId { get; } + public DateTime VersionDate { get; } + + public HistoricContentVersionMeta() { } + + public HistoricContentVersionMeta(int contentId, int contentTypeId, int versionId, DateTime versionDate) + { + ContentId = contentId; + ContentTypeId = contentTypeId; + VersionId = versionId; + VersionDate = versionDate; + } + + public override string ToString() => $"HistoricContentVersionMeta(versionId: {VersionId}, versionDate: {VersionDate:s}"; + } +} diff --git a/src/Umbraco.Core/Models/IContentType.cs b/src/Umbraco.Core/Models/IContentType.cs index f04a73d5e032..cf4a347112d1 100644 --- a/src/Umbraco.Core/Models/IContentType.cs +++ b/src/Umbraco.Core/Models/IContentType.cs @@ -1,56 +1,62 @@ using System.Collections.Generic; +using Umbraco.Cms.Core.Models.ContentEditing; namespace Umbraco.Cms.Core.Models { /// - /// Defines a ContentType, which Content is based on + /// Defines a ContentType, which Content is based on /// public interface IContentType : IContentTypeComposition { /// - /// Internal property to store the Id of the default template + /// Internal property to store the Id of the default template /// int DefaultTemplateId { get; set; } /// - /// Gets the default Template of the ContentType + /// Gets the default Template of the ContentType /// ITemplate DefaultTemplate { get; } /// - /// Gets or Sets a list of Templates which are allowed for the ContentType + /// Gets or Sets a list of Templates which are allowed for the ContentType /// IEnumerable AllowedTemplates { get; set; } /// - /// Determines if AllowedTemplates contains templateId + /// Gets or Sets the history cleanup configuration + /// + HistoryCleanup HistoryCleanup { get; set; } + + /// + /// Determines if AllowedTemplates contains templateId /// /// The template id to check /// True if AllowedTemplates contains the templateId else False bool IsAllowedTemplate(int templateId); /// - /// Determines if AllowedTemplates contains templateId + /// Determines if AllowedTemplates contains templateId /// /// The template alias to check /// True if AllowedTemplates contains the templateAlias else False bool IsAllowedTemplate(string templateAlias); /// - /// Sets the default template for the ContentType + /// Sets the default template for the ContentType /// - /// Default + /// Default void SetDefaultTemplate(ITemplate template); /// - /// Removes a template from the list of allowed templates + /// Removes a template from the list of allowed templates /// - /// to remove + /// to remove /// True if template was removed, otherwise False bool RemoveTemplate(ITemplate template); /// - /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset + /// Creates a deep clone of the current entity with its identity/alias and it's property identities reset /// /// /// diff --git a/src/Umbraco.Core/Models/Mapping/ContentTypeMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/ContentTypeMapDefinition.cs index d3269c2c0b54..9d41c4def984 100644 --- a/src/Umbraco.Core/Models/Mapping/ContentTypeMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/ContentTypeMapDefinition.cs @@ -2,9 +2,9 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; @@ -13,31 +13,49 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Models.Mapping { /// - /// Defines mappings for content/media/members type mappings + /// Defines mappings for content/media/members type mappings /// public class ContentTypeMapDefinition : IMapDefinition { private readonly CommonMapper _commonMapper; - private readonly PropertyEditorCollection _propertyEditors; + private readonly IContentTypeService _contentTypeService; private readonly IDataTypeService _dataTypeService; private readonly IFileService _fileService; - private readonly IContentTypeService _contentTypeService; + private readonly GlobalSettings _globalSettings; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; private readonly IMediaTypeService _mediaTypeService; private readonly IMemberTypeService _memberTypeService; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; + private readonly PropertyEditorCollection _propertyEditors; private readonly IShortStringHelper _shortStringHelper; - private readonly GlobalSettings _globalSettings; - private readonly IHostingEnvironment _hostingEnvironment; + private ContentSettings _contentSettings; - public ContentTypeMapDefinition(CommonMapper commonMapper, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IFileService fileService, - IContentTypeService contentTypeService, IMediaTypeService mediaTypeService, IMemberTypeService memberTypeService, - ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper, IOptions globalSettings, IHostingEnvironment hostingEnvironment) + + [Obsolete("Use ctor with all params injected")] + public ContentTypeMapDefinition(CommonMapper commonMapper, PropertyEditorCollection propertyEditors, + IDataTypeService dataTypeService, IFileService fileService, + IContentTypeService contentTypeService, IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper, IOptions globalSettings, + IHostingEnvironment hostingEnvironment) + : this(commonMapper, propertyEditors, dataTypeService, fileService, contentTypeService, mediaTypeService, memberTypeService, loggerFactory, shortStringHelper, globalSettings, hostingEnvironment, StaticServiceProvider.Instance.GetRequiredService>()) + { + + } + + public ContentTypeMapDefinition(CommonMapper commonMapper, PropertyEditorCollection propertyEditors, + IDataTypeService dataTypeService, IFileService fileService, + IContentTypeService contentTypeService, IMediaTypeService mediaTypeService, + IMemberTypeService memberTypeService, + ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper, IOptions globalSettings, + IHostingEnvironment hostingEnvironment, IOptionsMonitor contentSettings) { _commonMapper = commonMapper; _propertyEditors = propertyEditors; @@ -51,13 +69,19 @@ public ContentTypeMapDefinition(CommonMapper commonMapper, PropertyEditorCollect _shortStringHelper = shortStringHelper; _globalSettings = globalSettings.Value; _hostingEnvironment = hostingEnvironment; + + _contentSettings = contentSettings.CurrentValue; + contentSettings.OnChange(x => _contentSettings = x); } public void DefineMaps(IUmbracoMapper mapper) { - mapper.Define((source, context) => new ContentType(_shortStringHelper, source.ParentId), Map); - mapper.Define((source, context) => new MediaType(_shortStringHelper, source.ParentId), Map); - mapper.Define((source, context) => new MemberType(_shortStringHelper, source.ParentId), Map); + mapper.Define( + (source, context) => new ContentType(_shortStringHelper, source.ParentId), Map); + mapper.Define( + (source, context) => new MediaType(_shortStringHelper, source.ParentId), Map); + mapper.Define( + (source, context) => new MemberType(_shortStringHelper, source.ParentId), Map); mapper.Define((source, context) => new DocumentTypeDisplay(), Map); mapper.Define((source, context) => new MediaTypeDisplay(), Map); @@ -66,14 +90,20 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define( (source, context) => { - var dataType = _dataTypeService.GetDataType(source.DataTypeId); - if (dataType == null) throw new NullReferenceException("No data type found with id " + source.DataTypeId); + IDataType dataType = _dataTypeService.GetDataType(source.DataTypeId); + if (dataType == null) + { + throw new NullReferenceException("No data type found with id " + source.DataTypeId); + } + return new PropertyType(_shortStringHelper, dataType, source.Alias); }, Map); // TODO: isPublishing in ctor? - mapper.Define, PropertyGroup>((source, context) => new PropertyGroup(false), Map); - mapper.Define, PropertyGroup>((source, context) => new PropertyGroup(false), Map); + mapper.Define, PropertyGroup>( + (source, context) => new PropertyGroup(false), Map); + mapper.Define, PropertyGroup>( + (source, context) => new PropertyGroup(false), Map); mapper.Define((source, context) => new ContentTypeBasic(), Map); mapper.Define((source, context) => new ContentTypeBasic(), Map); @@ -84,11 +114,14 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define((source, context) => new MediaTypeDisplay(), Map); mapper.Define((source, context) => new MemberTypeDisplay(), Map); - mapper.Define, PropertyGroupDisplay>((source, context) => new PropertyGroupDisplay(), Map); - mapper.Define, PropertyGroupDisplay>((source, context) => new PropertyGroupDisplay(), Map); + mapper.Define, PropertyGroupDisplay>( + (source, context) => new PropertyGroupDisplay(), Map); + mapper.Define, PropertyGroupDisplay>( + (source, context) => new PropertyGroupDisplay(), Map); mapper.Define((source, context) => new PropertyTypeDisplay(), Map); - mapper.Define((source, context) => new MemberPropertyTypeDisplay(), Map); + mapper.Define( + (source, context) => new MemberPropertyTypeDisplay(), Map); } // no MapAll - take care @@ -97,13 +130,17 @@ private void Map(DocumentTypeSave source, IContentType target, MapperContext con MapSaveToTypeBase(source, target, context); MapComposition(source, target, alias => _contentTypeService.Get(alias)); + target.HistoryCleanup = source.HistoryCleanup; + target.AllowedTemplates = source.AllowedTemplates .Where(x => x != null) .Select(_fileService.GetTemplate) .Where(x => x != null) .ToArray(); - target.SetDefaultTemplate(source.DefaultTemplate == null ? null : _fileService.GetTemplate(source.DefaultTemplate)); + target.SetDefaultTemplate(source.DefaultTemplate == null + ? null + : _fileService.GetTemplate(source.DefaultTemplate)); } // no MapAll - take care @@ -119,11 +156,16 @@ private void Map(MemberTypeSave source, IMemberType target, MapperContext contex MapSaveToTypeBase(source, target, context); MapComposition(source, target, alias => _memberTypeService.Get(alias)); - foreach (var propertyType in source.Groups.SelectMany(x => x.Properties)) + foreach (MemberPropertyTypeBasic propertyType in source.Groups.SelectMany(x => x.Properties)) { - var localCopy = propertyType; - var destProp = target.PropertyTypes.SingleOrDefault(x => x.Alias.InvariantEquals(localCopy.Alias)); - if (destProp == null) continue; + MemberPropertyTypeBasic localCopy = propertyType; + IPropertyType destProp = + target.PropertyTypes.SingleOrDefault(x => x.Alias.InvariantEquals(localCopy.Alias)); + if (destProp == null) + { + continue; + } + target.SetMemberCanEditProperty(localCopy.Alias, localCopy.MemberCanEditProperty); target.SetMemberCanViewProperty(localCopy.Alias, localCopy.MemberCanViewProperty); target.SetIsSensitiveProperty(localCopy.Alias, localCopy.IsSensitiveData); @@ -135,6 +177,15 @@ private void Map(IContentType source, DocumentTypeDisplay target, MapperContext { MapTypeToDisplayBase(source, target); + target.HistoryCleanup = new HistoryCleanupViewModel + { + PreventCleanup = source.HistoryCleanup.PreventCleanup, + KeepAllVersionsNewerThanDays = source.HistoryCleanup.KeepAllVersionsNewerThanDays, + KeepLatestVersionPerDayForDays = source.HistoryCleanup.KeepLatestVersionPerDayForDays, + GlobalKeepAllVersionsNewerThanDays = _contentSettings.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays, + GlobalKeepLatestVersionPerDayForDays = _contentSettings.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays + }; + target.AllowCultureVariant = source.VariesByCulture(); target.AllowSegmentVariant = source.VariesBySegment(); target.ContentApps = _commonMapper.GetContentApps(source); @@ -143,16 +194,23 @@ private void Map(IContentType source, DocumentTypeDisplay target, MapperContext target.AllowedTemplates = context.MapEnumerable(source.AllowedTemplates); if (source.DefaultTemplate != null) + { target.DefaultTemplate = context.Map(source.DefaultTemplate); + } //default listview target.ListViewEditorName = Constants.Conventions.DataTypes.ListViewPrefix + "Content"; - if (string.IsNullOrEmpty(source.Alias)) return; + if (string.IsNullOrEmpty(source.Alias)) + { + return; + } var name = Constants.Conventions.DataTypes.ListViewPrefix + source.Alias; if (_dataTypeService.GetDataType(name) != null) + { target.ListViewEditorName = name; + } } // no MapAll - take care @@ -164,11 +222,16 @@ private void Map(IMediaType source, MediaTypeDisplay target, MapperContext conte target.ListViewEditorName = Constants.Conventions.DataTypes.ListViewPrefix + "Media"; target.IsSystemMediaType = source.IsSystemMediaType(); - if (string.IsNullOrEmpty(source.Name)) return; + if (string.IsNullOrEmpty(source.Name)) + { + return; + } var name = Constants.Conventions.DataTypes.ListViewPrefix + source.Name; if (_dataTypeService.GetDataType(name) != null) + { target.ListViewEditorName = name; + } } // no MapAll - take care @@ -177,11 +240,16 @@ private void Map(IMemberType source, MemberTypeDisplay target, MapperContext con MapTypeToDisplayBase(source, target); //map the MemberCanEditProperty,MemberCanViewProperty,IsSensitiveData - foreach (var propertyType in source.PropertyTypes) + foreach (IPropertyType propertyType in source.PropertyTypes) { - var localCopy = propertyType; - var displayProp = target.Groups.SelectMany(dest => dest.Properties).SingleOrDefault(dest => dest.Alias.InvariantEquals(localCopy.Alias)); - if (displayProp == null) continue; + IPropertyType localCopy = propertyType; + MemberPropertyTypeDisplay displayProp = target.Groups.SelectMany(dest => dest.Properties) + .SingleOrDefault(dest => dest.Alias.InvariantEquals(localCopy.Alias)); + if (displayProp == null) + { + continue; + } + displayProp.MemberCanEditProperty = source.MemberCanEditProperty(localCopy.Alias); displayProp.MemberCanViewProperty = source.MemberCanViewProperty(localCopy.Alias); displayProp.IsSensitiveData = source.IsSensitiveProperty(localCopy.Alias); @@ -216,28 +284,20 @@ private void Map(IContentTypeBase source, ContentTypeBasic target, string entity } // no MapAll - uses the IContentTypeBase map method, which has MapAll - private void Map(IContentTypeComposition source, ContentTypeBasic target, MapperContext context) - { + private void Map(IContentTypeComposition source, ContentTypeBasic target, MapperContext context) => Map(source, target, Constants.UdiEntityType.MemberType); - } // no MapAll - uses the IContentTypeBase map method, which has MapAll - private void Map(IContentType source, ContentTypeBasic target, MapperContext context) - { + private void Map(IContentType source, ContentTypeBasic target, MapperContext context) => Map(source, target, Constants.UdiEntityType.DocumentType); - } // no MapAll - uses the IContentTypeBase map method, which has MapAll - private void Map(IMediaType source, ContentTypeBasic target, MapperContext context) - { + private void Map(IMediaType source, ContentTypeBasic target, MapperContext context) => Map(source, target, Constants.UdiEntityType.MediaType); - } // no MapAll - uses the IContentTypeBase map method, which has MapAll - private void Map(IMemberType source, ContentTypeBasic target, MapperContext context) - { + private void Map(IMemberType source, ContentTypeBasic target, MapperContext context) => Map(source, target, Constants.UdiEntityType.MemberType); - } // Umbraco.Code.MapAll -CreateDate -DeleteDate -UpdateDate // Umbraco.Code.MapAll -SupportsPublishing -Key -PropertyEditorAlias -ValueStorageType -Variations @@ -254,10 +314,14 @@ private static void Map(PropertyTypeBasic source, IPropertyType target, MapperCo target.SetVariesBy(ContentVariation.Segment, source.AllowSegmentVariant); if (source.Id > 0) + { target.Id = source.Id; + } if (source.GroupId > 0) + { target.PropertyGroupId = new Lazy(() => source.GroupId, false); + } target.Alias = source.Alias; target.Description = source.Description; @@ -268,18 +332,19 @@ private static void Map(PropertyTypeBasic source, IPropertyType target, MapperCo // no MapAll - take care private void Map(DocumentTypeSave source, DocumentTypeDisplay target, MapperContext context) { - MapTypeToDisplayBase(source, target, context); + MapTypeToDisplayBase(source, + target, context); //sync templates - var destAllowedTemplateAliases = target.AllowedTemplates.Select(x => x.Alias); + IEnumerable destAllowedTemplateAliases = target.AllowedTemplates.Select(x => x.Alias); //if the dest is set and it's the same as the source, then don't change if (destAllowedTemplateAliases.SequenceEqual(source.AllowedTemplates) == false) { - var templates = _fileService.GetTemplates(source.AllowedTemplates.ToArray()); + IEnumerable templates = _fileService.GetTemplates(source.AllowedTemplates.ToArray()); target.AllowedTemplates = source.AllowedTemplates .Select(x => { - var template = templates.SingleOrDefault(t => t.Alias == x); + ITemplate template = templates.SingleOrDefault(t => t.Alias == x); return template != null ? context.Map(template) : null; @@ -293,7 +358,7 @@ private void Map(DocumentTypeSave source, DocumentTypeDisplay target, MapperCont //if the dest is set and it's the same as the source, then don't change if (target.DefaultTemplate == null || source.DefaultTemplate != target.DefaultTemplate.Alias) { - var template = _fileService.GetTemplate(source.DefaultTemplate); + ITemplate template = _fileService.GetTemplate(source.DefaultTemplate); target.DefaultTemplate = template == null ? null : context.Map(template); } } @@ -304,22 +369,24 @@ private void Map(DocumentTypeSave source, DocumentTypeDisplay target, MapperCont } // no MapAll - take care - private void Map(MediaTypeSave source, MediaTypeDisplay target, MapperContext context) - { - MapTypeToDisplayBase(source, target, context); - } + private void Map(MediaTypeSave source, MediaTypeDisplay target, MapperContext context) => + MapTypeToDisplayBase(source, + target, context); // no MapAll - take care - private void Map(MemberTypeSave source, MemberTypeDisplay target, MapperContext context) - { - MapTypeToDisplayBase(source, target, context); - } + private void Map(MemberTypeSave source, MemberTypeDisplay target, MapperContext context) => + MapTypeToDisplayBase( + source, target, context); // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate -Key -PropertyTypes - private static void Map(PropertyGroupBasic source, PropertyGroup target, MapperContext context) + private static void Map(PropertyGroupBasic source, PropertyGroup target, + MapperContext context) { if (source.Id > 0) + { target.Id = source.Id; + } + target.Key = source.Key; target.Type = source.Type; target.Name = source.Name; @@ -328,10 +395,14 @@ private static void Map(PropertyGroupBasic source, PropertyGr } // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate -Key -PropertyTypes - private static void Map(PropertyGroupBasic source, PropertyGroup target, MapperContext context) + private static void Map(PropertyGroupBasic source, PropertyGroup target, + MapperContext context) { if (source.Id > 0) + { target.Id = source.Id; + } + target.Key = source.Key; target.Type = source.Type; target.Name = source.Name; @@ -340,11 +411,15 @@ private static void Map(PropertyGroupBasic source, Prop } // Umbraco.Code.MapAll -ContentTypeId -ParentTabContentTypes -ParentTabContentTypeNames - private static void Map(PropertyGroupBasic source, PropertyGroupDisplay target, MapperContext context) + private static void Map(PropertyGroupBasic source, + PropertyGroupDisplay target, MapperContext context) { target.Inherited = source.Inherited; if (source.Id > 0) + { target.Id = source.Id; + } + target.Key = source.Key; target.Type = source.Type; target.Name = source.Name; @@ -354,17 +429,22 @@ private static void Map(PropertyGroupBasic source, PropertyGr } // Umbraco.Code.MapAll -ContentTypeId -ParentTabContentTypes -ParentTabContentTypeNames - private static void Map(PropertyGroupBasic source, PropertyGroupDisplay target, MapperContext context) + private static void Map(PropertyGroupBasic source, + PropertyGroupDisplay target, MapperContext context) { target.Inherited = source.Inherited; if (source.Id > 0) + { target.Id = source.Id; + } + target.Key = source.Key; target.Type = source.Type; target.Name = source.Name; target.Alias = source.Alias; target.SortOrder = source.SortOrder; - target.Properties = context.MapEnumerable(source.Properties); + target.Properties = + context.MapEnumerable(source.Properties); } // Umbraco.Code.MapAll -Editor -View -Config -ContentTypeId -ContentTypeName -Locked -DataTypeIcon -DataTypeName @@ -409,7 +489,8 @@ private static void Map(MemberPropertyTypeBasic source, MemberPropertyTypeDispla // Umbraco.Code.MapAll -CreatorId -Level -SortOrder -Variations // Umbraco.Code.MapAll -CreateDate -UpdateDate -DeleteDate // Umbraco.Code.MapAll -ContentTypeComposition (done by AfterMapSaveToType) - private static void MapSaveToTypeBase(TSource source, IContentTypeComposition target, MapperContext context) + private static void MapSaveToTypeBase(TSource source, + IContentTypeComposition target, MapperContext context) where TSource : ContentTypeSave where TSourcePropertyType : PropertyTypeBasic { @@ -418,7 +499,9 @@ private static void MapSaveToTypeBase(TSource sour var id = Convert.ToInt32(source.Id); if (id > 0) + { target.Id = id; + } target.Alias = source.Alias; target.Description = source.Description; @@ -457,18 +540,19 @@ private static void MapSaveToTypeBase(TSource sour // - managing the content type's PropertyTypes collection (for generic properties) // handle actual groups (non-generic-properties) - var destOrigGroups = target.PropertyGroups.ToArray(); // local groups - var destOrigProperties = target.PropertyTypes.ToArray(); // all properties, in groups or not + PropertyGroup[] destOrigGroups = target.PropertyGroups.ToArray(); // local groups + IPropertyType[] destOrigProperties = target.PropertyTypes.ToArray(); // all properties, in groups or not var destGroups = new List(); - var sourceGroups = source.Groups.Where(x => x.IsGenericProperties == false).ToArray(); + PropertyGroupBasic[] sourceGroups = + source.Groups.Where(x => x.IsGenericProperties == false).ToArray(); var sourceGroupParentAliases = sourceGroups.Select(x => x.GetParentAlias()).Distinct().ToArray(); - foreach (var sourceGroup in sourceGroups) + foreach (PropertyGroupBasic sourceGroup in sourceGroups) { // get the dest group - var destGroup = MapSaveGroup(sourceGroup, destOrigGroups, context); + PropertyGroup destGroup = MapSaveGroup(sourceGroup, destOrigGroups, context); // handle local properties - var destProperties = sourceGroup.Properties + IPropertyType[] destProperties = sourceGroup.Properties .Where(x => x.Inherited == false) .Select(x => MapSaveProperty(x, destOrigProperties, context)) .ToArray(); @@ -476,7 +560,9 @@ private static void MapSaveToTypeBase(TSource sour // if the group has no local properties and is not used as parent, skip it, ie sort-of garbage-collect // local groups which would not have local properties anymore if (destProperties.Length == 0 && !sourceGroupParentAliases.Contains(sourceGroup.Alias)) + { continue; + } // ensure no duplicate alias, then assign the group properties collection EnsureUniqueAliases(destProperties); @@ -492,11 +578,12 @@ private static void MapSaveToTypeBase(TSource sour // the old groups - they are just gone and will be cleared by the repository // handle non-grouped (ie generic) properties - var genericPropertiesGroup = source.Groups.FirstOrDefault(x => x.IsGenericProperties); + PropertyGroupBasic genericPropertiesGroup = + source.Groups.FirstOrDefault(x => x.IsGenericProperties); if (genericPropertiesGroup != null) { // handle local properties - var destProperties = genericPropertiesGroup.Properties + IPropertyType[] destProperties = genericPropertiesGroup.Properties .Where(x => x.Inherited == false) .Select(x => MapSaveProperty(x, destOrigProperties, context)) .ToArray(); @@ -547,7 +634,8 @@ private void MapTypeToDisplayBase(IContentTypeComp { MapTypeToDisplayBase(source, target); - var groupsMapper = new PropertyTypeGroupMapper(_propertyEditors, _dataTypeService, _shortStringHelper, _loggerFactory.CreateLogger>()); + var groupsMapper = new PropertyTypeGroupMapper(_propertyEditors, _dataTypeService, + _shortStringHelper, _loggerFactory.CreateLogger>()); target.Groups = groupsMapper.Map(source); } @@ -580,7 +668,8 @@ private void MapTypeToDisplayBase(ContentTypeSave source, ContentTypeComposition } // no MapAll - relies on the non-generic method - private void MapTypeToDisplayBase(TSource source, TTarget target, MapperContext context) + private void MapTypeToDisplayBase(TSource source, + TTarget target, MapperContext context) where TSource : ContentTypeSave where TSourcePropertyType : PropertyTypeBasic where TTarget : ContentTypeCompositionDisplay @@ -588,35 +677,49 @@ private void MapTypeToDisplayBase, PropertyGroupDisplay>(source.Groups); + target.Groups = + context + .MapEnumerable, PropertyGroupDisplay>( + source.Groups); } private IEnumerable MapLockedCompositions(IContentTypeComposition source) { // get ancestor ids from path of parent if not root if (source.ParentId == Constants.System.Root) + { return Enumerable.Empty(); + } - var parent = _contentTypeService.Get(source.ParentId); + IContentType parent = _contentTypeService.Get(source.ParentId); if (parent == null) + { return Enumerable.Empty(); + } var aliases = new List(); - var ancestorIds = parent.Path.Split(Constants.CharArrays.Comma).Select(s => int.Parse(s, CultureInfo.InvariantCulture)); + IEnumerable ancestorIds = parent.Path.Split(Constants.CharArrays.Comma) + .Select(s => int.Parse(s, CultureInfo.InvariantCulture)); // loop through all content types and return ordered aliases of ancestors - var allContentTypes = _contentTypeService.GetAll().ToArray(); + IContentType[] allContentTypes = _contentTypeService.GetAll().ToArray(); foreach (var ancestorId in ancestorIds) { - var ancestor = allContentTypes.FirstOrDefault(x => x.Id == ancestorId); + IContentType ancestor = allContentTypes.FirstOrDefault(x => x.Id == ancestorId); if (ancestor != null) + { aliases.Add(ancestor.Alias); + } } + return aliases.OrderBy(x => x); } public static Udi MapContentTypeUdi(IContentTypeComposition source) { - if (source == null) return null; + if (source == null) + { + return null; + } string udiType; switch (source) @@ -637,7 +740,8 @@ public static Udi MapContentTypeUdi(IContentTypeComposition source) return Udi.Create(udiType, source.Key); } - private static PropertyGroup MapSaveGroup(PropertyGroupBasic sourceGroup, IEnumerable destOrigGroups, MapperContext context) + private static PropertyGroup MapSaveGroup(PropertyGroupBasic sourceGroup, + IEnumerable destOrigGroups, MapperContext context) where TPropertyType : PropertyTypeBasic { PropertyGroup destGroup; @@ -663,7 +767,8 @@ private static PropertyGroup MapSaveGroup(PropertyGroupBasic destOrigProperties, MapperContext context) + private static IPropertyType MapSaveProperty(PropertyTypeBasic sourceProperty, + IEnumerable destOrigProperties, MapperContext context) { IPropertyType destProperty; if (sourceProperty.Id > 0) @@ -690,43 +795,52 @@ private static IPropertyType MapSaveProperty(PropertyTypeBasic sourceProperty, I private static void EnsureUniqueAliases(IEnumerable properties) { - var propertiesA = properties.ToArray(); + IPropertyType[] propertiesA = properties.ToArray(); var distinctProperties = propertiesA .Select(x => x.Alias?.ToUpperInvariant()) .Distinct() .Count(); if (distinctProperties != propertiesA.Length) + { throw new InvalidOperationException("Cannot map properties due to alias conflict."); + } } private static void EnsureUniqueAliases(IEnumerable groups) { - var groupsA = groups.ToArray(); + PropertyGroup[] groupsA = groups.ToArray(); var distinctProperties = groupsA .Select(x => x.Alias) .Distinct() .Count(); if (distinctProperties != groupsA.Length) + { throw new InvalidOperationException("Cannot map groups due to alias conflict."); + } } - private static void MapComposition(ContentTypeSave source, IContentTypeComposition target, Func getContentType) + private static void MapComposition(ContentTypeSave source, IContentTypeComposition target, + Func getContentType) { var current = target.CompositionAliases().ToArray(); - var proposed = source.CompositeContentTypes; + IEnumerable proposed = source.CompositeContentTypes; - var remove = current.Where(x => !proposed.Contains(x)); - var add = proposed.Where(x => !current.Contains(x)); + IEnumerable remove = current.Where(x => !proposed.Contains(x)); + IEnumerable add = proposed.Where(x => !current.Contains(x)); foreach (var alias in remove) + { target.RemoveContentType(alias); + } foreach (var alias in add) { // TODO: Remove N+1 lookup - var contentType = getContentType(alias); + IContentTypeComposition contentType = getContentType(alias); if (contentType != null) + { target.AddContentType(contentType); + } } } } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index f0f891d48e0a..34c6284993ad 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -26,6 +26,8 @@ public static class Tables public const string Content = TableNamePrefix + "Content"; public const string ContentVersion = TableNamePrefix + "ContentVersion"; public const string ContentVersionCultureVariation = TableNamePrefix + "ContentVersionCultureVariation"; + public const string ContentVersionCleanupPolicy = TableNamePrefix + "ContentVersionCleanupPolicy"; + public const string Document = TableNamePrefix + "Document"; public const string DocumentCultureVariation = TableNamePrefix + "DocumentCultureVariation"; public const string DocumentVersion = TableNamePrefix + "DocumentVersion"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs new file mode 100644 index 000000000000..bcdf4f8aebeb --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Persistence.Repositories +{ + public interface IDocumentVersionRepository : IRepository + { + /// + /// Gets a list of all historic content versions. + /// + public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup(); + + /// + /// Gets cleanup policy override settings per content type. + /// + public IReadOnlyCollection GetCleanupPolicies(); + + /// + /// Deletes multiple content versions by ID. + /// + void DeleteVersions(IEnumerable versionIds); + } +} diff --git a/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs b/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs new file mode 100644 index 000000000000..43718c801ffd --- /dev/null +++ b/src/Umbraco.Core/Services/IContentVersionCleanupPolicy.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Models; +using Umbraco.Core.Models; + +namespace Umbraco.Core.Services +{ + /// + /// Used to filter historic content versions for cleanup. + /// + public interface IContentVersionCleanupPolicy + { + /// + /// Filters a set of candidates historic content versions for cleanup according to policy settings. + /// + IEnumerable Apply(DateTime asAtDate, IEnumerable items); + } +} diff --git a/src/Umbraco.Core/Services/IContentVersionService.cs b/src/Umbraco.Core/Services/IContentVersionService.cs new file mode 100644 index 000000000000..656fd899348d --- /dev/null +++ b/src/Umbraco.Core/Services/IContentVersionService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using Umbraco.Cms.Core.Models; +using Umbraco.Core.Models; + +namespace Umbraco.Cms.Core.Services +{ + public interface IContentVersionService + { + /// + /// Removes historic content versions according to a policy. + /// + IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate); + } +} diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 8ed2205f5950..56cb1e6d01eb 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -15,20 +15,20 @@ - - - - - - - + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all @@ -56,6 +56,10 @@ - + + + + + diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 861a05b45907..c7a66373f7bf 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -43,6 +43,7 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs new file mode 100644 index 000000000000..6c97f7c80fd8 --- /dev/null +++ b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Core.Services; + +namespace Umbraco.Cms.Infrastructure.HostedServices +{ + /// + /// Recurring hosted service that executes the content history cleanup. + /// + public class ContentVersionCleanup : RecurringHostedServiceBase + { + private readonly IRuntimeState _runtimeState; + private readonly ILogger _logger; + private readonly IContentVersionService _service; + private readonly IMainDom _mainDom; + private readonly IServerRoleAccessor _serverRoleAccessor; + private ContentVersionCleanupPolicySettings _settings; + + /// + /// Initializes a new instance of the class. + /// + public ContentVersionCleanup( + IRuntimeState runtimeState, + ILogger logger, + IOptionsMonitor settings, + IContentVersionService service, + IMainDom mainDom, + IServerRoleAccessor serverRoleAccessor) + : base(TimeSpan.FromDays(1), TimeSpan.FromMinutes(1)) + { + _runtimeState = runtimeState; + _logger = logger; + _settings = settings.CurrentValue.ContentVersionCleanupPolicy; + _service = service; + _mainDom = mainDom; + _serverRoleAccessor = serverRoleAccessor; + + settings.OnChange(x => _settings = x.ContentVersionCleanupPolicy); + } + + /// + public override Task PerformExecuteAsync(object state) + { + // Globally disabled by feature flag + if (!_settings.EnableCleanup) + { + _logger.LogInformation("ContentVersionCleanup task will not run as it has been globally disabled via configuration"); + return Task.CompletedTask; + } + + if (_runtimeState.Level != RuntimeLevel.Run) + { + return Task.FromResult(true); // repeat... + } + + // Don't run on replicas nor unknown role servers + switch (_serverRoleAccessor.CurrentServerRole) + { + case ServerRole.Subscriber: + _logger.LogDebug("Does not run on subscriber servers"); + return Task.CompletedTask; + case ServerRole.Unknown: + _logger.LogDebug("Does not run on servers with unknown role"); + return Task.CompletedTask; + case ServerRole.Single: + case ServerRole.SchedulingPublisher: + default: + break; + } + + // Ensure we do not run if not main domain, but do NOT lock it + if (!_mainDom.IsMainDom) + { + _logger.LogDebug("Does not run if not MainDom"); + return Task.FromResult(false); // do NOT repeat, going down + } + + var count = _service.PerformContentVersionCleanup(DateTime.Now).Count; + + if (count > 0) + { + _logger.LogInformation("Deleted {count} ContentVersion(s)", count); + } + else + { + _logger.LogDebug("Task complete, no items were Deleted"); + } + + return Task.FromResult(true); + } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 4052a8ae56f1..c20c157604b4 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -12,21 +12,82 @@ using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; using Umbraco.Extensions; +using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo; namespace Umbraco.Cms.Infrastructure.Migrations.Install { /// - /// Creates the initial database schema during install. + /// Creates the initial database schema during install. /// public class DatabaseSchemaCreator { + // all tables, in order + internal static readonly List OrderedTables = new() + { + typeof(UserDto), + typeof(NodeDto), + typeof(ContentTypeDto), + typeof(TemplateDto), + typeof(ContentDto), + typeof(ContentVersionDto), + typeof(MediaVersionDto), + typeof(DocumentDto), + typeof(ContentTypeTemplateDto), + typeof(DataTypeDto), + typeof(DictionaryDto), + typeof(LanguageDto), + typeof(LanguageTextDto), + typeof(DomainDto), + typeof(LogDto), + typeof(MacroDto), + typeof(MacroPropertyDto), + typeof(MemberPropertyTypeDto), + typeof(MemberDto), + typeof(Member2MemberGroupDto), + typeof(PropertyTypeGroupDto), + typeof(PropertyTypeDto), + typeof(PropertyDataDto), + typeof(RelationTypeDto), + typeof(RelationDto), + typeof(TagDto), + typeof(TagRelationshipDto), + typeof(ContentType2ContentTypeDto), + typeof(ContentTypeAllowedContentTypeDto), + typeof(User2NodeNotifyDto), + typeof(ServerRegistrationDto), + typeof(AccessDto), + typeof(AccessRuleDto), + typeof(CacheInstructionDto), + typeof(ExternalLoginDto), + typeof(ExternalLoginTokenDto), + typeof(RedirectUrlDto), + typeof(LockDto), + typeof(UserGroupDto), + typeof(User2UserGroupDto), + typeof(UserGroup2NodePermissionDto), + typeof(UserGroup2AppDto), + typeof(UserStartNodeDto), + typeof(ContentNuDto), + typeof(DocumentVersionDto), + typeof(KeyValueDto), + typeof(UserLoginDto), + typeof(ConsentDto), + typeof(AuditEntryDto), + typeof(ContentVersionCultureVariationDto), + typeof(DocumentCultureVariationDto), + typeof(ContentScheduleDto), + typeof(LogViewerQueryDto), + typeof(ContentVersionCleanupPolicyDto) + }; + private readonly IUmbracoDatabase _database; + private readonly IEventAggregator _eventAggregator; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private readonly IUmbracoVersion _umbracoVersion; - private readonly IEventAggregator _eventAggregator; - public DatabaseSchemaCreator(IUmbracoDatabase database, ILogger logger, ILoggerFactory loggerFactory, IUmbracoVersion umbracoVersion, IEventAggregator eventAggregator) + public DatabaseSchemaCreator(IUmbracoDatabase database, ILogger logger, + ILoggerFactory loggerFactory, IUmbracoVersion umbracoVersion, IEventAggregator eventAggregator) { _database = database ?? throw new ArgumentNullException(nameof(database)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -42,74 +103,16 @@ public DatabaseSchemaCreator(IUmbracoDatabase database, ILogger _database.SqlContext.SqlSyntax; - // all tables, in order - internal static readonly List OrderedTables = new List - { - typeof (UserDto), - typeof (NodeDto), - typeof (ContentTypeDto), - typeof (TemplateDto), - typeof (ContentDto), - typeof (ContentVersionDto), - typeof (MediaVersionDto), - typeof (DocumentDto), - typeof (ContentTypeTemplateDto), - typeof (DataTypeDto), - typeof (DictionaryDto), - typeof (LanguageDto), - typeof (LanguageTextDto), - typeof (DomainDto), - typeof (LogDto), - typeof (MacroDto), - typeof (MacroPropertyDto), - typeof (MemberPropertyTypeDto), - typeof (MemberDto), - typeof (Member2MemberGroupDto), - typeof (PropertyTypeGroupDto), - typeof (PropertyTypeDto), - typeof (PropertyDataDto), - typeof (RelationTypeDto), - typeof (RelationDto), - typeof (TagDto), - typeof (TagRelationshipDto), - typeof (ContentType2ContentTypeDto), - typeof (ContentTypeAllowedContentTypeDto), - typeof (User2NodeNotifyDto), - typeof (ServerRegistrationDto), - typeof (AccessDto), - typeof (AccessRuleDto), - typeof (CacheInstructionDto), - typeof (ExternalLoginDto), - typeof (ExternalLoginTokenDto), - typeof (RedirectUrlDto), - typeof (LockDto), - typeof (UserGroupDto), - typeof (User2UserGroupDto), - typeof (UserGroup2NodePermissionDto), - typeof (UserGroup2AppDto), - typeof (UserStartNodeDto), - typeof (ContentNuDto), - typeof (DocumentVersionDto), - typeof (KeyValueDto), - typeof (UserLoginDto), - typeof (ConsentDto), - typeof (AuditEntryDto), - typeof (ContentVersionCultureVariationDto), - typeof (DocumentCultureVariationDto), - typeof (ContentScheduleDto), - typeof (LogViewerQueryDto) - }; - /// - /// Drops all Umbraco tables in the db. + /// Drops all Umbraco tables in the db. /// internal void UninstallDatabaseSchema() { _logger.LogInformation("Start UninstallDatabaseSchema"); - foreach (var table in OrderedTables.AsEnumerable().Reverse()) + foreach (Type table in OrderedTables.AsEnumerable().Reverse()) { - var tableNameAttribute = table.FirstAttribute(); + TableNameAttribute tableNameAttribute = table.FirstAttribute(); var tableName = tableNameAttribute == null ? table.Name : tableNameAttribute.Value; _logger.LogInformation("Uninstall {TableName}", tableName); @@ -117,7 +120,9 @@ internal void UninstallDatabaseSchema() try { if (TableExists(tableName)) + { DropTable(tableName); + } } catch (Exception ex) { @@ -129,13 +134,15 @@ internal void UninstallDatabaseSchema() } /// - /// Initializes the database by creating the umbraco db schema. + /// Initializes the database by creating the umbraco db schema. /// /// This needs to execute as part of a transaction. public void InitializeDatabaseSchema() { if (!_database.InTransaction) + { throw new InvalidOperationException("Database is not in a transaction."); + } var eventMessages = new EventMessages(); var creatingNotification = new DatabaseSchemaCreatingNotification(eventMessages); @@ -143,22 +150,23 @@ public void InitializeDatabaseSchema() if (creatingNotification.Cancel == false) { - var dataCreation = new DatabaseDataCreator(_database, _loggerFactory.CreateLogger(), _umbracoVersion); - foreach (var table in OrderedTables) + var dataCreation = new DatabaseDataCreator(_database, + _loggerFactory.CreateLogger(), _umbracoVersion); + foreach (Type table in OrderedTables) + { CreateTable(false, table, dataCreation); + } } - DatabaseSchemaCreatedNotification createdNotification = new DatabaseSchemaCreatedNotification(eventMessages).WithStateFrom(creatingNotification); + DatabaseSchemaCreatedNotification createdNotification = + new DatabaseSchemaCreatedNotification(eventMessages).WithStateFrom(creatingNotification); FireAfterCreation(createdNotification); } /// - /// Validates the schema of the current database. + /// Validates the schema of the current database. /// - internal DatabaseSchemaResult ValidateSchema() - { - return ValidateSchema(OrderedTables); - } + internal DatabaseSchemaResult ValidateSchema() => ValidateSchema(OrderedTables); internal DatabaseSchemaResult ValidateSchema(IEnumerable orderedTables) { @@ -179,26 +187,30 @@ internal DatabaseSchemaResult ValidateSchema(IEnumerable orderedTables) } /// - /// This validates the Primary/Foreign keys in the database + /// This validates the Primary/Foreign keys in the database /// /// /// - /// This does not validate any database constraints that are not PKs or FKs because Umbraco does not create a database with non PK/FK constraints. - /// Any unique "constraints" in the database are done with unique indexes. + /// This does not validate any database constraints that are not PKs or FKs because Umbraco does not create a database + /// with non PK/FK constraints. + /// Any unique "constraints" in the database are done with unique indexes. /// private void ValidateDbConstraints(DatabaseSchemaResult result) { //Check constraints in configured database against constraints in schema var constraintsInDatabase = SqlSyntax.GetConstraintsPerColumn(_database).DistinctBy(x => x.Item3).ToList(); - var foreignKeysInDatabase = constraintsInDatabase.Where(x => x.Item3.InvariantStartsWith("FK_")).Select(x => x.Item3).ToList(); - var primaryKeysInDatabase = constraintsInDatabase.Where(x => x.Item3.InvariantStartsWith("PK_")).Select(x => x.Item3).ToList(); + var foreignKeysInDatabase = constraintsInDatabase.Where(x => x.Item3.InvariantStartsWith("FK_")) + .Select(x => x.Item3).ToList(); + var primaryKeysInDatabase = constraintsInDatabase.Where(x => x.Item3.InvariantStartsWith("PK_")) + .Select(x => x.Item3).ToList(); var unknownConstraintsInDatabase = constraintsInDatabase.Where( x => - x.Item3.InvariantStartsWith("FK_") == false && x.Item3.InvariantStartsWith("PK_") == false && - x.Item3.InvariantStartsWith("IX_") == false).Select(x => x.Item3).ToList(); - var foreignKeysInSchema = result.TableDefinitions.SelectMany(x => x.ForeignKeys.Select(y => y.Name)).ToList(); + x.Item3.InvariantStartsWith("FK_") == false && x.Item3.InvariantStartsWith("PK_") == false && + x.Item3.InvariantStartsWith("IX_") == false).Select(x => x.Item3).ToList(); + var foreignKeysInSchema = + result.TableDefinitions.SelectMany(x => x.ForeignKeys.Select(y => y.Name)).ToList(); var primaryKeysInSchema = result.TableDefinitions.SelectMany(x => x.Columns.Select(y => y.PrimaryKeyName)) .Where(x => x.IsNullOrWhiteSpace() == false).ToList(); @@ -219,14 +231,17 @@ private void ValidateDbConstraints(DatabaseSchemaResult result) //Foreign keys: - var validForeignKeyDifferences = foreignKeysInDatabase.Intersect(foreignKeysInSchema, StringComparer.InvariantCultureIgnoreCase); + IEnumerable validForeignKeyDifferences = + foreignKeysInDatabase.Intersect(foreignKeysInSchema, StringComparer.InvariantCultureIgnoreCase); foreach (var foreignKey in validForeignKeyDifferences) { result.ValidConstraints.Add(foreignKey); } - var invalidForeignKeyDifferences = + + IEnumerable invalidForeignKeyDifferences = foreignKeysInDatabase.Except(foreignKeysInSchema, StringComparer.InvariantCultureIgnoreCase) - .Union(foreignKeysInSchema.Except(foreignKeysInDatabase, StringComparer.InvariantCultureIgnoreCase)); + .Union(foreignKeysInSchema.Except(foreignKeysInDatabase, + StringComparer.InvariantCultureIgnoreCase)); foreach (var foreignKey in invalidForeignKeyDifferences) { result.Errors.Add(new Tuple("Constraint", foreignKey)); @@ -236,37 +251,43 @@ private void ValidateDbConstraints(DatabaseSchemaResult result) //Primary keys: //Add valid and invalid primary key differences to the result object - var validPrimaryKeyDifferences = primaryKeysInDatabase.Intersect(primaryKeysInSchema, StringComparer.InvariantCultureIgnoreCase); + IEnumerable validPrimaryKeyDifferences = + primaryKeysInDatabase.Intersect(primaryKeysInSchema, StringComparer.InvariantCultureIgnoreCase); foreach (var primaryKey in validPrimaryKeyDifferences) { result.ValidConstraints.Add(primaryKey); } - var invalidPrimaryKeyDifferences = + + IEnumerable invalidPrimaryKeyDifferences = primaryKeysInDatabase.Except(primaryKeysInSchema, StringComparer.InvariantCultureIgnoreCase) - .Union(primaryKeysInSchema.Except(primaryKeysInDatabase, StringComparer.InvariantCultureIgnoreCase)); + .Union(primaryKeysInSchema.Except(primaryKeysInDatabase, + StringComparer.InvariantCultureIgnoreCase)); foreach (var primaryKey in invalidPrimaryKeyDifferences) { result.Errors.Add(new Tuple("Constraint", primaryKey)); } - } private void ValidateDbColumns(DatabaseSchemaResult result) { //Check columns in configured database against columns in schema - var columnsInDatabase = SqlSyntax.GetColumnsInSchema(_database); - var columnsPerTableInDatabase = columnsInDatabase.Select(x => string.Concat(x.TableName, ",", x.ColumnName)).ToList(); - var columnsPerTableInSchema = result.TableDefinitions.SelectMany(x => x.Columns.Select(y => string.Concat(y.TableName, ",", y.Name))).ToList(); + IEnumerable columnsInDatabase = SqlSyntax.GetColumnsInSchema(_database); + var columnsPerTableInDatabase = + columnsInDatabase.Select(x => string.Concat(x.TableName, ",", x.ColumnName)).ToList(); + var columnsPerTableInSchema = result.TableDefinitions + .SelectMany(x => x.Columns.Select(y => string.Concat(y.TableName, ",", y.Name))).ToList(); //Add valid and invalid column differences to the result object - var validColumnDifferences = columnsPerTableInDatabase.Intersect(columnsPerTableInSchema, StringComparer.InvariantCultureIgnoreCase); + IEnumerable validColumnDifferences = + columnsPerTableInDatabase.Intersect(columnsPerTableInSchema, StringComparer.InvariantCultureIgnoreCase); foreach (var column in validColumnDifferences) { result.ValidColumns.Add(column); } - var invalidColumnDifferences = + IEnumerable invalidColumnDifferences = columnsPerTableInDatabase.Except(columnsPerTableInSchema, StringComparer.InvariantCultureIgnoreCase) - .Union(columnsPerTableInSchema.Except(columnsPerTableInDatabase, StringComparer.InvariantCultureIgnoreCase)); + .Union(columnsPerTableInSchema.Except(columnsPerTableInDatabase, + StringComparer.InvariantCultureIgnoreCase)); foreach (var column in invalidColumnDifferences) { result.Errors.Add(new Tuple("Column", column)); @@ -279,15 +300,16 @@ private void ValidateDbTables(DatabaseSchemaResult result) var tablesInDatabase = SqlSyntax.GetTablesInSchema(_database).ToList(); var tablesInSchema = result.TableDefinitions.Select(x => x.Name).ToList(); //Add valid and invalid table differences to the result object - var validTableDifferences = tablesInDatabase.Intersect(tablesInSchema, StringComparer.InvariantCultureIgnoreCase); + IEnumerable validTableDifferences = + tablesInDatabase.Intersect(tablesInSchema, StringComparer.InvariantCultureIgnoreCase); foreach (var tableName in validTableDifferences) { result.ValidTables.Add(tableName); } - var invalidTableDifferences = + IEnumerable invalidTableDifferences = tablesInDatabase.Except(tablesInSchema, StringComparer.InvariantCultureIgnoreCase) - .Union(tablesInSchema.Except(tablesInDatabase, StringComparer.InvariantCultureIgnoreCase)); + .Union(tablesInSchema.Except(tablesInDatabase, StringComparer.InvariantCultureIgnoreCase)); foreach (var tableName in invalidTableDifferences) { result.Errors.Add(new Tuple("Table", tableName)); @@ -302,15 +324,16 @@ private void ValidateDbIndexes(DatabaseSchemaResult result) var indexesInSchema = result.TableDefinitions.SelectMany(x => x.Indexes.Select(y => y.Name)).ToList(); //Add valid and invalid index differences to the result object - var validColIndexDifferences = colIndexesInDatabase.Intersect(indexesInSchema, StringComparer.InvariantCultureIgnoreCase); + IEnumerable validColIndexDifferences = + colIndexesInDatabase.Intersect(indexesInSchema, StringComparer.InvariantCultureIgnoreCase); foreach (var index in validColIndexDifferences) { result.ValidIndexes.Add(index); } - var invalidColIndexDifferences = + IEnumerable invalidColIndexDifferences = colIndexesInDatabase.Except(indexesInSchema, StringComparer.InvariantCultureIgnoreCase) - .Union(indexesInSchema.Except(colIndexesInDatabase, StringComparer.InvariantCultureIgnoreCase)); + .Union(indexesInSchema.Except(colIndexesInDatabase, StringComparer.InvariantCultureIgnoreCase)); foreach (var index in invalidColIndexDifferences) { result.Errors.Add(new Tuple("Index", index)); @@ -320,14 +343,14 @@ private void ValidateDbIndexes(DatabaseSchemaResult result) #region Notifications /// - /// Publishes the notification. + /// Publishes the notification. /// /// Cancelable notification marking the creation having begun. internal virtual void FireBeforeCreation(DatabaseSchemaCreatingNotification notification) => _eventAggregator.Publish(notification); /// - /// Publishes the notification. + /// Publishes the notification. /// /// Notification marking the creation having completed. internal virtual void FireAfterCreation(DatabaseSchemaCreatedNotification notification) => @@ -338,30 +361,27 @@ internal virtual void FireAfterCreation(DatabaseSchemaCreatedNotification notifi #region Utilities /// - /// Returns whether a table with the specified exists in the database. + /// Returns whether a table with the specified exists in the database. /// /// The name of the table. /// true if the table exists; otherwise false. /// - /// + /// /// if (schemaHelper.TableExist("MyTable")) /// { /// // do something when the table exists /// } /// /// - public bool TableExists(string tableName) - { - return SqlSyntax.DoesTableExist(_database, tableName); - } + public bool TableExists(string tableName) => SqlSyntax.DoesTableExist(_database, tableName); /// - /// Returns whether the table for the specified exists in the database. + /// Returns whether the table for the specified exists in the database. /// /// The type representing the DTO/table. /// true if the table exists; otherwise false. /// - /// + /// /// if (schemaHelper.TableExist<MyDto>) /// { /// // do something when the table exists @@ -369,66 +389,67 @@ public bool TableExists(string tableName) /// /// /// - /// If has been decorated with an , the name from that - /// attribute will be used for the table name. If the attribute is not present, the name - /// will be used instead. + /// If has been decorated with an , the name from that + /// attribute will be used for the table name. If the attribute is not present, the name + /// will be used instead. /// public bool TableExists() { - var table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); + TableDefinition table = DefinitionFactory.GetTableDefinition(typeof(T), SqlSyntax); return table != null && TableExists(table.Name); } /// - /// Creates a new table in the database based on the type of . + /// Creates a new table in the database based on the type of . /// /// The type representing the DTO/table. /// Whether the table should be overwritten if it already exists. /// - /// If has been decorated with an , the name from that - /// attribute will be used for the table name. If the attribute is not present, the name - /// will be used instead. - /// - /// If a table with the same name already exists, the parameter will determine - /// whether the table is overwritten. If true, the table will be overwritten, whereas this method will - /// not do anything if the parameter is false. + /// If has been decorated with an , the name from that + /// attribute will be used for the table name. If the attribute is not present, the name + /// will be used instead. + /// If a table with the same name already exists, the parameter will determine + /// whether the table is overwritten. If true, the table will be overwritten, whereas this method will + /// not do anything if the parameter is false. /// internal void CreateTable(bool overwrite = false) where T : new() { - var tableType = typeof(T); - CreateTable(overwrite, tableType, new DatabaseDataCreator(_database, _loggerFactory.CreateLogger(), _umbracoVersion)); + Type tableType = typeof(T); + CreateTable(overwrite, tableType, + new DatabaseDataCreator(_database, _loggerFactory.CreateLogger(), + _umbracoVersion)); } /// - /// Creates a new table in the database for the specified . + /// Creates a new table in the database for the specified . /// /// Whether the table should be overwritten if it already exists. /// The representing the table. /// /// - /// If has been decorated with an , the name from - /// that attribute will be used for the table name. If the attribute is not present, the name - /// will be used instead. - /// - /// If a table with the same name already exists, the parameter will determine - /// whether the table is overwritten. If true, the table will be overwritten, whereas this method will - /// not do anything if the parameter is false. - /// - /// This need to execute as part of a transaction. + /// If has been decorated with an , the name from + /// that attribute will be used for the table name. If the attribute is not present, the name + /// will be used instead. + /// If a table with the same name already exists, the parameter will determine + /// whether the table is overwritten. If true, the table will be overwritten, whereas this method will + /// not do anything if the parameter is false. + /// This need to execute as part of a transaction. /// internal void CreateTable(bool overwrite, Type modelType, DatabaseDataCreator dataCreation) { if (!_database.InTransaction) + { throw new InvalidOperationException("Database is not in a transaction."); + } - var tableDefinition = DefinitionFactory.GetTableDefinition(modelType, SqlSyntax); + TableDefinition tableDefinition = DefinitionFactory.GetTableDefinition(modelType, SqlSyntax); var tableName = tableDefinition.Name; var createSql = SqlSyntax.Format(tableDefinition); var createPrimaryKeySql = SqlSyntax.FormatPrimaryKey(tableDefinition); - var foreignSql = SqlSyntax.Format(tableDefinition.ForeignKeys); - var indexSql = SqlSyntax.Format(tableDefinition.Indexes); + List foreignSql = SqlSyntax.Format(tableDefinition.ForeignKeys); + List indexSql = SqlSyntax.Format(tableDefinition.Indexes); var tableExist = TableExists(tableName); if (overwrite && tableExist) @@ -458,7 +479,9 @@ internal void CreateTable(bool overwrite, Type modelType, DatabaseDataCreator da } if (SqlSyntax.SupportsIdentityInsert() && tableDefinition.Columns.Any(x => x.IsIdentity)) + { _database.Execute(new Sql($"SET IDENTITY_INSERT {SqlSyntax.GetQuotedTableName(tableName)} ON ")); + } //Call the NewTable-event to trigger the insert of base/default data //OnNewTable(tableName, _db, e, _logger); @@ -466,7 +489,9 @@ internal void CreateTable(bool overwrite, Type modelType, DatabaseDataCreator da dataCreation.InitializeBaseData(tableName); if (SqlSyntax.SupportsIdentityInsert() && tableDefinition.Columns.Any(x => x.IsIdentity)) + { _database.Execute(new Sql($"SET IDENTITY_INSERT {SqlSyntax.GetQuotedTableName(tableName)} OFF;")); + } //Loop through index statements and execute sql foreach (var sql in indexSql) @@ -489,23 +514,22 @@ internal void CreateTable(bool overwrite, Type modelType, DatabaseDataCreator da else { _logger.LogInformation("New table {TableName} was created", tableName); - } } /// - /// Drops the table for the specified . + /// Drops the table for the specified . /// /// The type representing the DTO/table. /// - /// + /// /// schemaHelper.DropTable<MyDto>); /// /// /// - /// If has been decorated with an , the name from that - /// attribute will be used for the table name. If the attribute is not present, the name - /// will be used instead. + /// If has been decorated with an , the name from that + /// attribute will be used for the table name. If the attribute is not present, the name + /// will be used instead. /// public void DropTable(string tableName) { diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index ea629e5c3afb..2e33ab044442 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -1,4 +1,5 @@ using System; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; using Umbraco.Cms.Core.Semver; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.Common; @@ -12,47 +13,88 @@ using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_7_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_8_9_0; using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_9_0_0; +using Umbraco.Core.Migrations.Upgrade.V_9_1_0; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade { /// - /// Represents the Umbraco CMS migration plan. + /// Represents the Umbraco CMS migration plan. /// /// public class UmbracoPlan : MigrationPlan { - private readonly IUmbracoVersion _umbracoVersion; private const string InitPrefix = "{init-"; private const string InitSuffix = "}"; + private readonly IUmbracoVersion _umbracoVersion; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The Umbraco version. public UmbracoPlan(IUmbracoVersion umbracoVersion) - : base(Core.Constants.Conventions.Migrations.UmbracoUpgradePlanName) + : base(Constants.Conventions.Migrations.UmbracoUpgradePlanName) { _umbracoVersion = umbracoVersion; DefinePlan(); } + /// + /// + /// The default initial state in plans is string.Empty. + /// + /// When upgrading from version 7, we want to use specific initial states + /// that are e.g. "{init-7.9.3}", "{init-7.11.1}", etc. so we can chain the proper + /// migrations. + /// + /// + /// This is also where we detect the current version, and reject invalid + /// upgrades (from a tool old version, or going back in time, etc). + /// + /// + public override string InitialState + { + get + { + SemVersion currentVersion = _umbracoVersion.SemanticVersion; + + // only from 8.0.0 and above + var minVersion = new SemVersion(8); + if (currentVersion < minVersion) + { + throw new InvalidOperationException( + $"Version {currentVersion} cannot be migrated to {_umbracoVersion.SemanticVersion}." + + $" Please upgrade first to at least {minVersion}."); + } + + // Force versions between 7.14.*-7.15.* into into 7.14 initial state. Because there is no db-changes, + // and we don't want users to workaround my putting in version 7.14.0 them self. + if (minVersion <= currentVersion && currentVersion < new SemVersion(7, 16)) + { + return GetInitState(minVersion); + } + + // initial state is eg "{init-7.14.0}" + return GetInitState(currentVersion); + } + } + /// - /// Gets the initial state corresponding to a version. + /// Gets the initial state corresponding to a version. /// /// The version. /// - /// The initial state. + /// The initial state. /// private static string GetInitState(SemVersion version) => InitPrefix + version + InitSuffix; /// - /// Tries to extract a version from an initial state. + /// Tries to extract a version from an initial state. /// /// The state. /// The version. /// - /// true when the state contains a version; otherwise, false.D + /// true when the state contains a version; otherwise, false.D /// private static bool TryGetInitStateVersion(string state, out string version) { @@ -66,51 +108,21 @@ private static bool TryGetInitStateVersion(string state, out string version) return false; } - /// - /// - /// The default initial state in plans is string.Empty. - /// When upgrading from version 7, we want to use specific initial states - /// that are e.g. "{init-7.9.3}", "{init-7.11.1}", etc. so we can chain the proper - /// migrations. - /// This is also where we detect the current version, and reject invalid - /// upgrades (from a tool old version, or going back in time, etc). - /// - public override string InitialState - { - get - { - var currentVersion = _umbracoVersion.SemanticVersion; - - // only from 8.0.0 and above - var minVersion = new SemVersion(8, 0); - if (currentVersion < minVersion) - throw new InvalidOperationException($"Version {currentVersion} cannot be migrated to {_umbracoVersion.SemanticVersion}." - + $" Please upgrade first to at least {minVersion}."); - - // Force versions between 7.14.*-7.15.* into into 7.14 initial state. Because there is no db-changes, - // and we don't want users to workaround my putting in version 7.14.0 them self. - if (minVersion <= currentVersion && currentVersion < new SemVersion(7, 16)) - return GetInitState(minVersion); - - // initial state is eg "{init-7.14.0}" - return GetInitState(currentVersion); - } - } - /// public override void ThrowOnUnknownInitialState(string state) { if (TryGetInitStateVersion(state, out var initVersion)) { - throw new InvalidOperationException($"Version {_umbracoVersion.SemanticVersion} does not support migrating from {initVersion}." - + $" Please verify which versions support migrating from {initVersion}."); + throw new InvalidOperationException( + $"Version {_umbracoVersion.SemanticVersion} does not support migrating from {initVersion}." + + $" Please verify which versions support migrating from {initVersion}."); } base.ThrowOnUnknownInitialState(state); } /// - /// Defines the plan. + /// Defines the plan. /// protected void DefinePlan() { @@ -131,7 +143,7 @@ protected void DefinePlan() // plan starts at 7.14.0 (anything before 7.14.0 is not supported) - From(GetInitState(new SemVersion(7, 14, 0))); + From(GetInitState(new SemVersion(7, 14))); // begin migrating from v7 - remove all keys and indexes To("{B36B9ABD-374E-465B-9C5F-26AB0D39326F}"); @@ -146,14 +158,15 @@ protected void DefinePlan() To("{9DF05B77-11D1-475C-A00A-B656AF7E0908}"); To("{6FE3EF34-44A0-4992-B379-B40BC4EF1C4D}"); To("{7F59355A-0EC9-4438-8157-EB517E6D2727}"); - ToWithReplace("{941B2ABA-2D06-4E04-81F5-74224F1DB037}", "{76DF5CD7-A884-41A5-8DC6-7860D95B1DF5}"); // kill AddVariationTable1 + ToWithReplace("{941B2ABA-2D06-4E04-81F5-74224F1DB037}", + "{76DF5CD7-A884-41A5-8DC6-7860D95B1DF5}"); // kill AddVariationTable1 To("{A7540C58-171D-462A-91C5-7A9AA5CB8BFD}"); Merge() .To("{3E44F712-E2E3-473A-AE49-5D7F8E67CE3F}") - .With() + .With() .To("{65D6B71C-BDD5-4A2E-8D35-8896325E9151}") - .As("{4CACE351-C6B9-4F0C-A6BA-85A02BBD39E4}"); + .As("{4CACE351-C6B9-4F0C-A6BA-85A02BBD39E4}"); To("{1350617A-4930-4D61-852F-E3AA9E692173}"); To("{CF51B39B-9B9A-4740-BB7C-EAF606A7BFBF}"); @@ -175,9 +188,9 @@ protected void DefinePlan() Merge() .To("{CDBEDEE4-9496-4903-9CF2-4104E00FF960}") - .With() + .With() .To("{940FD19A-00A8-4D5C-B8FF-939143585726}") - .As("{0576E786-5C30-4000-B969-302B61E90CA3}"); + .As("{0576E786-5C30-4000-B969-302B61E90CA3}"); To("{48AD6CCD-C7A4-4305-A8AB-38728AD23FC5}"); To("{DF470D86-E5CA-42AC-9780-9D28070E25F9}"); @@ -241,13 +254,15 @@ protected void DefinePlan() To("{3D8DADEF-0FDA-4377-A5F0-B52C2110E8F2}"); To("{1303BDCF-2295-4645-9526-2F32E8B35ABD}"); To("{5060F3D2-88BE-4D30-8755-CF51F28EAD12}"); - To("{A2686B49-A082-4B22-97FD-AAB154D46A57}"); // Re-run this migration to make sure it has executed to account for migrations going out of sync between versions. + To( + "{A2686B49-A082-4B22-97FD-AAB154D46A57}"); // Re-run this migration to make sure it has executed to account for migrations going out of sync between versions. // TO 9.0.0-rc4 - To("5E02F241-5253-403D-B5D3-7DB00157E20F"); // Jaddie: This GUID is missing the { }, although this likely can't be changed now as it will break installs going forwards - - // TO 9.0.0 + To( + "5E02F241-5253-403D-B5D3-7DB00157E20F"); // Jaddie: This GUID is missing the { }, although this likely can't be changed now as it will break installs going forwards + // TO 9.1.0 + To("{8BAF5E6C-DCB7-41AE-824F-4215AE4F1F98}"); } } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_1_0/AddContentVersionCleanupFeature.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_1_0/AddContentVersionCleanupFeature.cs new file mode 100644 index 000000000000..b9729e53f034 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_9_1_0/AddContentVersionCleanupFeature.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Infrastructure.Migrations; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Core.Migrations.Upgrade.V_9_1_0 +{ + class AddContentVersionCleanupFeature : MigrationBase + { + public AddContentVersionCleanupFeature(IMigrationContext context) + : base(context) { } + + /// + /// The conditionals are useful to enable the same migration to be used in multiple + /// migration paths x.x -> 8.18 and x.x -> 9.x + /// + protected override void Migrate() + { + var tables = SqlSyntax.GetTablesInSchema(Context.Database); + if (!tables.InvariantContains(ContentVersionCleanupPolicyDto.TableName)) + { + Create.Table().Do(); + } + + var columns = SqlSyntax.GetColumnsInSchema(Context.Database); + AddColumnIfNotExists(columns, "preventCleanup"); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs new file mode 100644 index 000000000000..f0350d9f1bee --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionCleanupPolicyDto.cs @@ -0,0 +1,35 @@ +using System; +using System.Data; +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos +{ + [TableName(TableName)] + [PrimaryKey("contentTypeId", AutoIncrement = false)] + [ExplicitColumns] + internal class ContentVersionCleanupPolicyDto + { + public const string TableName = Constants.DatabaseSchema.Tables.ContentVersionCleanupPolicy; + + [Column("contentTypeId")] + [ForeignKey(typeof(ContentTypeDto), Column = "nodeId", OnDelete = Rule.Cascade)] + public int ContentTypeId { get; set; } + + [Column("keepAllVersionsNewerThanDays")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? KeepAllVersionsNewerThanDays { get; set; } + + [Column("keepLatestVersionPerDayForDays")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? KeepLatestVersionPerDayForDays { get; set; } + + [Column("preventCleanup")] + public bool PreventCleanup { get; set; } + + [Column("updated")] + public DateTime Updated { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionDto.cs index 53e90859d9c3..63f2802af625 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentVersionDto.cs @@ -49,5 +49,9 @@ public class ContentVersionDto [ResultColumn] [Reference(ReferenceType.OneToOne, ColumnName = "NodeId", ReferenceMemberName = "NodeId")] public ContentDto ContentDto { get; set; } + + [Column("preventCleanup")] + [Constraint(Default = "0")] + public bool PreventCleanup { get; set; } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs index e171a3a54f77..2a14a745c9a1 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeCommonRepository.cs @@ -6,31 +6,35 @@ using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Extensions; +using Enumerable = System.Linq.Enumerable; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { /// - /// Implements . + /// Implements . /// internal class ContentTypeCommonRepository : IContentTypeCommonRepository { - private const string CacheKey = "Umbraco.Core.Persistence.Repositories.Implement.ContentTypeCommonRepository::AllTypes"; + private const string CacheKey = + "Umbraco.Core.Persistence.Repositories.Implement.ContentTypeCommonRepository::AllTypes"; private readonly AppCaches _appCaches; - private readonly IShortStringHelper _shortStringHelper; private readonly IScopeAccessor _scopeAccessor; + private readonly IShortStringHelper _shortStringHelper; private readonly ITemplateRepository _templateRepository; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public ContentTypeCommonRepository(IScopeAccessor scopeAccessor, ITemplateRepository templateRepository, AppCaches appCaches, IShortStringHelper shortStringHelper) + public ContentTypeCommonRepository(IScopeAccessor scopeAccessor, ITemplateRepository templateRepository, + AppCaches appCaches, IShortStringHelper shortStringHelper) { _scopeAccessor = scopeAccessor; _templateRepository = templateRepository; @@ -40,83 +44,98 @@ public ContentTypeCommonRepository(IScopeAccessor scopeAccessor, ITemplateReposi private IScope AmbientScope => _scopeAccessor.AmbientScope; private IUmbracoDatabase Database => AmbientScope.Database; + private ISqlContext SqlContext => AmbientScope.SqlContext; - private Sql Sql() => SqlContext.Sql(); //private Sql Sql(string sql, params object[] args) => SqlContext.Sql(sql, args); //private ISqlSyntaxProvider SqlSyntax => SqlContext.SqlSyntax; //private IQuery Query() => SqlContext.Query(); /// - public IEnumerable GetAllTypes() - { + public IEnumerable GetAllTypes() => // use a 5 minutes sliding cache - same as FullDataSet cache policy - return _appCaches.RuntimeCache.GetCacheItem(CacheKey, GetAllTypesInternal, TimeSpan.FromMinutes(5), true); - } + _appCaches.RuntimeCache.GetCacheItem(CacheKey, GetAllTypesInternal, TimeSpan.FromMinutes(5), true); /// - public void ClearCache() - { - _appCaches.RuntimeCache.Clear(CacheKey); - } + public void ClearCache() => _appCaches.RuntimeCache.Clear(CacheKey); + + private Sql Sql() => SqlContext.Sql(); private IEnumerable GetAllTypesInternal() { var contentTypes = new Dictionary(); // get content types - var sql1 = Sql() + Sql sql1 = Sql() .Select(r => r.Select(x => x.NodeDto)) .From() .InnerJoin().On((ct, n) => ct.NodeId == n.NodeId) .OrderBy(x => x.NodeId); - var contentTypeDtos = Database.Fetch(sql1); + List contentTypeDtos = Database.Fetch(sql1); // get allowed content types - var sql2 = Sql() + Sql sql2 = Sql() .Select() .From() .OrderBy(x => x.Id); - var allowedDtos = Database.Fetch(sql2); + List allowedDtos = Database.Fetch(sql2); // prepare // note: same alias could be used for media, content... but always different ids = ok - var aliases = contentTypeDtos.ToDictionary(x => x.NodeId, x => x.Alias); + var aliases = Enumerable.ToDictionary(contentTypeDtos, x => x.NodeId, x => x.Alias); // create var allowedDtoIx = 0; - foreach (var contentTypeDto in contentTypeDtos) + foreach (ContentTypeDto contentTypeDto in contentTypeDtos) { // create content type IContentTypeComposition contentType; - if (contentTypeDto.NodeDto.NodeObjectType == Cms.Core.Constants.ObjectTypes.MediaType) + if (contentTypeDto.NodeDto.NodeObjectType == Constants.ObjectTypes.MediaType) + { contentType = ContentTypeFactory.BuildMediaTypeEntity(_shortStringHelper, contentTypeDto); - else if (contentTypeDto.NodeDto.NodeObjectType == Cms.Core.Constants.ObjectTypes.DocumentType) + } + else if (contentTypeDto.NodeDto.NodeObjectType == Constants.ObjectTypes.DocumentType) + { contentType = ContentTypeFactory.BuildContentTypeEntity(_shortStringHelper, contentTypeDto); - else if (contentTypeDto.NodeDto.NodeObjectType == Cms.Core.Constants.ObjectTypes.MemberType) + } + else if (contentTypeDto.NodeDto.NodeObjectType == Constants.ObjectTypes.MemberType) + { contentType = ContentTypeFactory.BuildMemberTypeEntity(_shortStringHelper, contentTypeDto); - else throw new PanicException($"The node object type {contentTypeDto.NodeDto.NodeObjectType} is not supported"); + } + else + { + throw new PanicException( + $"The node object type {contentTypeDto.NodeDto.NodeObjectType} is not supported"); + } + contentTypes.Add(contentType.Id, contentType); // map allowed content types var allowedContentTypes = new List(); while (allowedDtoIx < allowedDtos.Count && allowedDtos[allowedDtoIx].Id == contentTypeDto.NodeId) { - var allowedDto = allowedDtos[allowedDtoIx]; - if (!aliases.TryGetValue(allowedDto.AllowedId, out var alias)) continue; - allowedContentTypes.Add(new ContentTypeSort(new Lazy(() => allowedDto.AllowedId), allowedDto.SortOrder, alias)); + ContentTypeAllowedContentTypeDto allowedDto = allowedDtos[allowedDtoIx]; + if (!aliases.TryGetValue(allowedDto.AllowedId, out var alias)) + { + continue; + } + + allowedContentTypes.Add(new ContentTypeSort(new Lazy(() => allowedDto.AllowedId), + allowedDto.SortOrder, alias)); allowedDtoIx++; } + contentType.AllowedContentTypes = allowedContentTypes; } MapTemplates(contentTypes); MapComposition(contentTypes); MapGroupsAndProperties(contentTypes); + MapHistoryCleanup(contentTypes); // finalize - foreach (var contentType in contentTypes.Values) + foreach (IContentTypeComposition contentType in contentTypes.Values) { contentType.ResetDirtyProperties(false); } @@ -124,36 +143,79 @@ private IEnumerable GetAllTypesInternal() return contentTypes.Values; } + private void MapHistoryCleanup(Dictionary contentTypes) + { + // get templates + Sql sql1 = Sql() + .Select() + .From() + .OrderBy(x => x.ContentTypeId); + + var contentVersionCleanupPolicyDtos = Database.Fetch(sql1); + + var contentVersionCleanupPolicyDictionary = + contentVersionCleanupPolicyDtos.ToDictionary(x => x.ContentTypeId); + foreach (IContentTypeComposition c in contentTypes.Values) + { + if (!(c is ContentType contentType)) + { + continue; + } + + var historyCleanup = new HistoryCleanup(); + + if (contentVersionCleanupPolicyDictionary.TryGetValue(contentType.Id, out var versionCleanup)) + { + historyCleanup.PreventCleanup = versionCleanup.PreventCleanup; + historyCleanup.KeepAllVersionsNewerThanDays = versionCleanup.KeepAllVersionsNewerThanDays; + historyCleanup.KeepLatestVersionPerDayForDays = versionCleanup.KeepLatestVersionPerDayForDays; + } + + contentType.HistoryCleanup = historyCleanup; + } + } + private void MapTemplates(Dictionary contentTypes) { // get templates - var sql1 = Sql() + Sql sql1 = Sql() .Select() .From() .OrderBy(x => x.ContentTypeNodeId); - var templateDtos = Database.Fetch(sql1); + List templateDtos = Database.Fetch(sql1); //var templates = templateRepository.GetMany(templateDtos.Select(x => x.TemplateNodeId).ToArray()).ToDictionary(x => x.Id, x => x); - var templates = _templateRepository.GetMany().ToDictionary(x => x.Id, x => x); + var templates = Enumerable.ToDictionary(_templateRepository.GetMany(), x => x.Id, x => x); var templateDtoIx = 0; - foreach (var c in contentTypes.Values) + foreach (IContentTypeComposition c in contentTypes.Values) { - if (!(c is IContentType contentType)) continue; + if (!(c is IContentType contentType)) + { + continue; + } // map allowed templates var allowedTemplates = new List(); var defaultTemplateId = 0; - while (templateDtoIx < templateDtos.Count && templateDtos[templateDtoIx].ContentTypeNodeId == contentType.Id) + while (templateDtoIx < templateDtos.Count && + templateDtos[templateDtoIx].ContentTypeNodeId == contentType.Id) { - var allowedDto = templateDtos[templateDtoIx]; + ContentTypeTemplateDto allowedDto = templateDtos[templateDtoIx]; templateDtoIx++; - if (!templates.TryGetValue(allowedDto.TemplateNodeId, out var template)) continue; + if (!templates.TryGetValue(allowedDto.TemplateNodeId, out ITemplate template)) + { + continue; + } + allowedTemplates.Add(template); if (allowedDto.IsDefault) + { defaultTemplateId = template.Id; + } } + contentType.AllowedTemplates = allowedTemplates; contentType.DefaultTemplateId = defaultTemplateId; } @@ -162,24 +224,28 @@ private void MapTemplates(Dictionary contentTypes) private void MapComposition(IDictionary contentTypes) { // get parent/child - var sql1 = Sql() + Sql sql1 = Sql() .Select() .From() .OrderBy(x => x.ChildId); - var compositionDtos = Database.Fetch(sql1); + List compositionDtos = Database.Fetch(sql1); // map var compositionIx = 0; - foreach (var contentType in contentTypes.Values) + foreach (IContentTypeComposition contentType in contentTypes.Values) { - while (compositionIx < compositionDtos.Count && compositionDtos[compositionIx].ChildId == contentType.Id) + while (compositionIx < compositionDtos.Count && + compositionDtos[compositionIx].ChildId == contentType.Id) { - var parentDto = compositionDtos[compositionIx]; + ContentType2ContentTypeDto parentDto = compositionDtos[compositionIx]; compositionIx++; - if (!contentTypes.TryGetValue(parentDto.ParentId, out var parentContentType)) + if (!contentTypes.TryGetValue(parentDto.ParentId, out IContentTypeComposition parentContentType)) + { continue; + } + contentType.AddContentType(parentContentType); } } @@ -187,41 +253,49 @@ private void MapComposition(IDictionary contentTyp private void MapGroupsAndProperties(IDictionary contentTypes) { - var sql1 = Sql() + Sql sql1 = Sql() .Select() .From() - .InnerJoin().On((ptg, ct) => ptg.ContentTypeNodeId == ct.NodeId) + .InnerJoin() + .On((ptg, ct) => ptg.ContentTypeNodeId == ct.NodeId) .OrderBy(x => x.NodeId) .AndBy(x => x.SortOrder, x => x.Id); - var groupDtos = Database.Fetch(sql1); + List groupDtos = Database.Fetch(sql1); - var sql2 = Sql() + Sql sql2 = Sql() .Select(r => r.Select(x => x.DataTypeDto, r1 => r1.Select(x => x.NodeDto))) .AndSelect() .From() .InnerJoin().On((pt, dt) => pt.DataTypeId == dt.NodeId) .InnerJoin().On((dt, n) => dt.NodeId == n.NodeId) - .InnerJoin().On((pt, ct) => pt.ContentTypeId == ct.NodeId) - .LeftJoin().On((pt, ptg) => pt.PropertyTypeGroupId == ptg.Id) - .LeftJoin().On((pt, mpt) => pt.Id == mpt.PropertyTypeId) + .InnerJoin() + .On((pt, ct) => pt.ContentTypeId == ct.NodeId) + .LeftJoin() + .On((pt, ptg) => pt.PropertyTypeGroupId == ptg.Id) + .LeftJoin() + .On((pt, mpt) => pt.Id == mpt.PropertyTypeId) .OrderBy(x => x.NodeId) - .AndBy(x => x.SortOrder, x => x.Id) // NULLs will come first or last, never mind, we deal with it below + .AndBy< + PropertyTypeGroupDto>(x => x.SortOrder, + x => x.Id) // NULLs will come first or last, never mind, we deal with it below .AndBy(x => x.SortOrder, x => x.Id); - var propertyDtos = Database.Fetch(sql2); - var builtinProperties = ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); + List propertyDtos = Database.Fetch(sql2); + Dictionary builtinProperties = + ConventionsHelper.GetStandardPropertyTypeStubs(_shortStringHelper); var groupIx = 0; var propertyIx = 0; - foreach (var contentType in contentTypes.Values) + foreach (IContentTypeComposition contentType in contentTypes.Values) { // only IContentType is publishing var isPublishing = contentType is IContentType; // get group-less properties (in case NULL is ordered first) var noGroupPropertyTypes = new PropertyTypeCollection(isPublishing); - while (propertyIx < propertyDtos.Count && propertyDtos[propertyIx].ContentTypeId == contentType.Id && propertyDtos[propertyIx].PropertyTypeGroupId == null) + while (propertyIx < propertyDtos.Count && propertyDtos[propertyIx].ContentTypeId == contentType.Id && + propertyDtos[propertyIx].PropertyTypeGroupId == null) { noGroupPropertyTypes.Add(MapPropertyType(contentType, propertyDtos[propertyIx], builtinProperties)); propertyIx++; @@ -231,36 +305,45 @@ private void MapGroupsAndProperties(IDictionary co var groupCollection = new PropertyGroupCollection(); while (groupIx < groupDtos.Count && groupDtos[groupIx].ContentTypeNodeId == contentType.Id) { - var group = MapPropertyGroup(groupDtos[groupIx], isPublishing); + PropertyGroup group = MapPropertyGroup(groupDtos[groupIx], isPublishing); groupCollection.Add(group); groupIx++; - while (propertyIx < propertyDtos.Count && propertyDtos[propertyIx].ContentTypeId == contentType.Id && propertyDtos[propertyIx].PropertyTypeGroupId == group.Id) + while (propertyIx < propertyDtos.Count && + propertyDtos[propertyIx].ContentTypeId == contentType.Id && + propertyDtos[propertyIx].PropertyTypeGroupId == group.Id) { - group.PropertyTypes.Add(MapPropertyType(contentType, propertyDtos[propertyIx], builtinProperties)); + group.PropertyTypes.Add(MapPropertyType(contentType, propertyDtos[propertyIx], + builtinProperties)); propertyIx++; } } + contentType.PropertyGroups = groupCollection; // get group-less properties (in case NULL is ordered last) - while (propertyIx < propertyDtos.Count && propertyDtos[propertyIx].ContentTypeId == contentType.Id && propertyDtos[propertyIx].PropertyTypeGroupId == null) + while (propertyIx < propertyDtos.Count && propertyDtos[propertyIx].ContentTypeId == contentType.Id && + propertyDtos[propertyIx].PropertyTypeGroupId == null) { noGroupPropertyTypes.Add(MapPropertyType(contentType, propertyDtos[propertyIx], builtinProperties)); propertyIx++; } + contentType.NoGroupPropertyTypes = noGroupPropertyTypes; // ensure builtin properties if (contentType is IMemberType memberType) { // ensure that the group exists (ok if it already exists) - memberType.AddPropertyGroup(Cms.Core.Constants.Conventions.Member.StandardPropertiesGroupAlias, Cms.Core.Constants.Conventions.Member.StandardPropertiesGroupName); + memberType.AddPropertyGroup(Constants.Conventions.Member.StandardPropertiesGroupAlias, + Constants.Conventions.Member.StandardPropertiesGroupName); // ensure that property types exist (ok if they already exist) - foreach (var (alias, propertyType) in builtinProperties) + foreach ((var alias, PropertyType propertyType) in builtinProperties) { - var added = memberType.AddPropertyType(propertyType, Cms.Core.Constants.Conventions.Member.StandardPropertiesGroupAlias, Cms.Core.Constants.Conventions.Member.StandardPropertiesGroupName); + var added = memberType.AddPropertyType(propertyType, + Constants.Conventions.Member.StandardPropertiesGroupAlias, + Constants.Conventions.Member.StandardPropertiesGroupName); if (added) { @@ -273,9 +356,8 @@ private void MapGroupsAndProperties(IDictionary co } } - private PropertyGroup MapPropertyGroup(PropertyTypeGroupDto dto, bool isPublishing) - { - return new PropertyGroup(new PropertyTypeCollection(isPublishing)) + private PropertyGroup MapPropertyGroup(PropertyTypeGroupDto dto, bool isPublishing) => + new PropertyGroup(new PropertyTypeCollection(isPublishing)) { Id = dto.Id, Key = dto.UniqueId, @@ -284,14 +366,16 @@ private PropertyGroup MapPropertyGroup(PropertyTypeGroupDto dto, bool isPublishi Alias = dto.Alias, SortOrder = dto.SortOrder }; - } - private PropertyType MapPropertyType(IContentTypeComposition contentType, PropertyTypeCommonDto dto, IDictionary builtinProperties) + private PropertyType MapPropertyType(IContentTypeComposition contentType, PropertyTypeCommonDto dto, + IDictionary builtinProperties) { var groupId = dto.PropertyTypeGroupId; - var readonlyStorageType = builtinProperties.TryGetValue(dto.Alias, out var propertyType); - var storageType = readonlyStorageType ? propertyType.ValueStorageType : Enum.Parse(dto.DataTypeDto.DbType); + var readonlyStorageType = builtinProperties.TryGetValue(dto.Alias, out PropertyType propertyType); + ValueStorageType storageType = readonlyStorageType + ? propertyType.ValueStorageType + : Enum.Parse(dto.DataTypeDto.DbType); if (contentType is IMemberType memberType) { @@ -300,23 +384,25 @@ private PropertyType MapPropertyType(IContentTypeComposition contentType, Proper memberType.SetMemberCanViewProperty(dto.Alias, dto.ViewOnProfile); } - return new PropertyType(_shortStringHelper, dto.DataTypeDto.EditorAlias, storageType, readonlyStorageType, dto.Alias) - { - Description = dto.Description, - DataTypeId = dto.DataTypeId, - DataTypeKey = dto.DataTypeDto.NodeDto.UniqueId, - Id = dto.Id, - Key = dto.UniqueId, - Mandatory = dto.Mandatory, - MandatoryMessage = dto.MandatoryMessage, - Name = dto.Name, - PropertyGroupId = groupId.HasValue ? new Lazy(() => groupId.Value) : null, - SortOrder = dto.SortOrder, - ValidationRegExp = dto.ValidationRegExp, - ValidationRegExpMessage = dto.ValidationRegExpMessage, - Variations = (ContentVariation)dto.Variations, - LabelOnTop = dto.LabelOnTop - }; + return new + PropertyType(_shortStringHelper, dto.DataTypeDto.EditorAlias, storageType, readonlyStorageType, + dto.Alias) + { + Description = dto.Description, + DataTypeId = dto.DataTypeId, + DataTypeKey = dto.DataTypeDto.NodeDto.UniqueId, + Id = dto.Id, + Key = dto.UniqueId, + Mandatory = dto.Mandatory, + MandatoryMessage = dto.MandatoryMessage, + Name = dto.Name, + PropertyGroupId = groupId.HasValue ? new Lazy(() => groupId.Value) : null, + SortOrder = dto.SortOrder, + ValidationRegExp = dto.ValidationRegExp, + ValidationRegExpMessage = dto.ValidationRegExpMessage, + Variations = (ContentVariation)dto.Variations, + LabelOnTop = dto.LabelOnTop + }; } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepository.cs index 1977e0fce128..9a1ea90f6012 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepository.cs @@ -289,8 +289,22 @@ protected override void PersistUpdatedItem(IContentType entity) PersistUpdatedBaseContentType(entity); PersistTemplates(entity, true); + PersistHistoryCleanup(entity); entity.ResetDirtyProperties(); } + + private void PersistHistoryCleanup(IContentType entity) + { + ContentVersionCleanupPolicyDto dto = new ContentVersionCleanupPolicyDto() + { + ContentTypeId = entity.Id, + Updated = DateTime.Now, + PreventCleanup = entity.HistoryCleanup.PreventCleanup, + KeepAllVersionsNewerThanDays = entity.HistoryCleanup.KeepAllVersionsNewerThanDays, + KeepLatestVersionPerDayForDays = entity.HistoryCleanup.KeepLatestVersionPerDayForDays, + }; + Database.InsertOrUpdate(dto); + } } } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs index 75d66cf09d7f..12e094cd7cb7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentRepository.cs @@ -292,9 +292,11 @@ public override IEnumerable GetAllVersionsSlim(int nodeId, int skip, i .OrderByDescending(x => x.Current) .AndByDescending(x => x.VersionDate); - return MapDtosToContent(Database.Fetch(sql), true, + var pageIndex = skip / take; + + return MapDtosToContent(Database.Page(pageIndex+1, take, sql).Items, true, // load bare minimum, need variants though since this is used to rollback with variants - false, false, false, true).Skip(skip).Take(take); + false, false, false, true); } public override IContent GetVersion(int versionId) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs new file mode 100644 index 000000000000..1067637f401c --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement +{ + internal class DocumentVersionRepository : IDocumentVersionRepository + { + private readonly IScopeAccessor _scopeAccessor; + + public DocumentVersionRepository(IScopeAccessor scopeAccessor) + { + _scopeAccessor = scopeAccessor ?? throw new ArgumentNullException(nameof(scopeAccessor)); + } + + /// + /// + /// Never includes current draft version.
+ /// Never includes current published version.
+ /// Never includes versions marked as "preventCleanup".
+ ///
+ public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup() + { + var query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select(@"umbracoDocument.nodeId as contentId, + umbracoContent.contentTypeId as contentTypeId, + umbracoContentVersion.id as versionId, + umbracoContentVersion.versionDate as versionDate") + .From() + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.NodeId, right => right.NodeId) + .InnerJoin() + .On(left => left.Id, right => right.Id) + .Where(x => !x.Current) // Never delete current draft version + .Where(x => !x.PreventCleanup) // Never delete "pinned" versions + .Where(x => !x.Published); // Never delete published version + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + public IReadOnlyCollection GetCleanupPolicies() + { + var query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + /// + /// Deletes in batches of + /// + public void DeleteVersions(IEnumerable versionIds) + { + foreach (var group in versionIds.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + var groupedVersionIds = group.ToList(); + + // Note: We had discussed doing this in a single SQL Command. + // If you can work out how to make that work with SQL CE, let me know! + // Can use test PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive to try things out. + + var query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.VersionId, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + + query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Delete() + .WhereIn(x => x.Id, groupedVersionIds); + _scopeAccessor.AmbientScope.Database.Execute(query); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/ContentService.cs b/src/Umbraco.Infrastructure/Services/Implement/ContentService.cs index 21c365dd8e5f..f8669a77b46e 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/ContentService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/ContentService.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Querying; @@ -19,27 +19,29 @@ namespace Umbraco.Cms.Core.Services.Implement { /// - /// Implements the content service. + /// Implements the content service. /// public class ContentService : RepositoryService, IContentService { - private readonly IDocumentRepository _documentRepository; - private readonly IEntityRepository _entityRepository; private readonly IAuditRepository _auditRepository; private readonly IContentTypeRepository _contentTypeRepository; private readonly IDocumentBlueprintRepository _documentBlueprintRepository; + private readonly IDocumentRepository _documentRepository; + private readonly IEntityRepository _entityRepository; private readonly ILanguageRepository _languageRepository; + private readonly ILogger _logger; private readonly Lazy _propertyValidationService; private readonly IShortStringHelper _shortStringHelper; - private readonly ILogger _logger; private IQuery _queryNotTrashed; #region Constructors public ContentService(IScopeProvider provider, ILoggerFactory loggerFactory, IEventMessagesFactory eventMessagesFactory, - IDocumentRepository documentRepository, IEntityRepository entityRepository, IAuditRepository auditRepository, - IContentTypeRepository contentTypeRepository, IDocumentBlueprintRepository documentBlueprintRepository, ILanguageRepository languageRepository, + IDocumentRepository documentRepository, IEntityRepository entityRepository, + IAuditRepository auditRepository, + IContentTypeRepository contentTypeRepository, IDocumentBlueprintRepository documentBlueprintRepository, + ILanguageRepository languageRepository, Lazy propertyValidationService, IShortStringHelper shortStringHelper) : base(provider, loggerFactory, eventMessagesFactory) { @@ -60,7 +62,73 @@ public ContentService(IScopeProvider provider, ILoggerFactory loggerFactory, // lazy-constructed because when the ctor runs, the query factory may not be ready - private IQuery QueryNotTrashed => _queryNotTrashed ?? (_queryNotTrashed = Query().Where(x => x.Trashed == false)); + private IQuery QueryNotTrashed => + _queryNotTrashed ?? (_queryNotTrashed = Query().Where(x => x.Trashed == false)); + + #endregion + + #region Rollback + + public OperationResult Rollback(int id, int versionId, string culture = "*", + int userId = Constants.Security.SuperUserId) + { + EventMessages evtMsgs = EventMessagesFactory.Get(); + + //Get the current copy of the node + IContent content = GetById(id); + + //Get the version + IContent version = GetVersion(versionId); + + //Good ole null checks + if (content == null || version == null || content.Trashed) + { + return new OperationResult(OperationResultType.FailedCannot, evtMsgs); + } + + //Store the result of doing the save of content for the rollback + OperationResult rollbackSaveResult; + + using (IScope scope = ScopeProvider.CreateScope()) + { + var rollingBackNotification = new ContentRollingBackNotification(content, evtMsgs); + if (scope.Notifications.PublishCancelable(rollingBackNotification)) + { + scope.Complete(); + return OperationResult.Cancel(evtMsgs); + } + + //Copy the changes from the version + content.CopyFrom(version, culture); + + //Save the content for the rollback + rollbackSaveResult = Save(content, userId); + + //Depending on the save result - is what we log & audit along with what we return + if (rollbackSaveResult.Success == false) + { + //Log the error/warning + _logger.LogError( + "User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, + id, versionId); + } + else + { + scope.Notifications.Publish( + new ContentRolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification)); + + //Logging & Audit message + _logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", + userId, id, versionId); + Audit(AuditType.RollBack, userId, id, + $"Content '{content.Name}' was rolled back to version '{versionId}'"); + } + + scope.Complete(); + } + + return rollbackSaveResult; + } #endregion @@ -68,36 +136,36 @@ public ContentService(IScopeProvider provider, ILoggerFactory loggerFactory, public int CountPublished(string contentTypeAlias = null) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountPublished(contentTypeAlias); } } public int Count(string contentTypeAlias = null) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Count(contentTypeAlias); } } public int CountChildren(int parentId, string contentTypeAlias = null) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountChildren(parentId, contentTypeAlias); } } public int CountDescendants(int parentId, string contentTypeAlias = null) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.CountDescendants(parentId, contentTypeAlias); } } @@ -107,46 +175,46 @@ public int CountDescendants(int parentId, string contentTypeAlias = null) #region Permissions /// - /// Used to bulk update the permissions set for a content item. This will replace all permissions - /// assigned to an entity with a list of user id & permission pairs. + /// Used to bulk update the permissions set for a content item. This will replace all permissions + /// assigned to an entity with a list of user id & permission pairs. /// /// public void SetPermissions(EntityPermissionSet permissionSet) { - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.ReplaceContentPermissions(permissionSet); scope.Complete(); } } /// - /// Assigns a single permission to the current content item for the specified group ids + /// Assigns a single permission to the current content item for the specified group ids /// /// /// /// public void SetPermission(IContent entity, char permission, IEnumerable groupIds) { - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.AssignEntityPermission(entity, permission, groupIds); scope.Complete(); } } /// - /// Returns implicit/inherited permissions assigned to the content item for all user groups + /// Returns implicit/inherited permissions assigned to the content item for all user groups /// /// /// public EntityPermissionCollection GetPermissions(IContent content) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetPermissionsForEntity(content.Id); } } @@ -156,53 +224,59 @@ public EntityPermissionCollection GetPermissions(IContent content) #region Create /// - /// Creates an object using the alias of the - /// that this Content should based on. + /// Creates an object using the alias of the + /// that this Content should based on. /// /// - /// Note that using this method will simply return a new IContent without any identity - /// as it has not yet been persisted. It is intended as a shortcut to creating new content objects - /// that does not invoke a save operation against the database. + /// Note that using this method will simply return a new IContent without any identity + /// as it has not yet been persisted. It is intended as a shortcut to creating new content objects + /// that does not invoke a save operation against the database. /// /// Name of the Content object /// Id of Parent for the new Content - /// Alias of the + /// Alias of the /// Optional id of the user creating the content - /// - public IContent Create(string name, Guid parentId, string contentTypeAlias, int userId = Cms.Core.Constants.Security.SuperUserId) + /// + /// + /// + public IContent Create(string name, Guid parentId, string contentTypeAlias, + int userId = Constants.Security.SuperUserId) { // TODO: what about culture? - var parent = GetById(parentId); + IContent parent = GetById(parentId); return Create(name, parent, contentTypeAlias, userId); } /// - /// Creates an object of a specified content type. + /// Creates an object of a specified content type. /// - /// This method simply returns a new, non-persisted, IContent without any identity. It - /// is intended as a shortcut to creating new content objects that does not invoke a save - /// operation against the database. + /// + /// This method simply returns a new, non-persisted, IContent without any identity. It + /// is intended as a shortcut to creating new content objects that does not invoke a save + /// operation against the database. /// /// The name of the content object. /// The identifier of the parent, or -1. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. - public IContent Create(string name, int parentId, string contentTypeAlias, int userId = Cms.Core.Constants.Security.SuperUserId) + public IContent Create(string name, int parentId, string contentTypeAlias, + int userId = Constants.Security.SuperUserId) { // TODO: what about culture? - var contentType = GetContentType(contentTypeAlias); + IContentType contentType = GetContentType(contentTypeAlias); return Create(name, parentId, contentType, userId); } /// - /// Creates an object of a specified content type. + /// Creates an object of a specified content type. /// - /// This method simply returns a new, non-persisted, IContent without any identity. It - /// is intended as a shortcut to creating new content objects that does not invoke a save - /// operation against the database. + /// + /// This method simply returns a new, non-persisted, IContent without any identity. It + /// is intended as a shortcut to creating new content objects that does not invoke a save + /// operation against the database. /// /// The name of the content object. /// The identifier of the parent, or -1. @@ -217,7 +291,7 @@ public IContent Create(string name, int parentId, IContentType contentType, throw new ArgumentException("Content type must be specified", nameof(contentType)); } - var parent = parentId > 0 ? GetById(parentId) : null; + IContent parent = parentId > 0 ? GetById(parentId) : null; if (parentId > 0 && parent is null) { throw new ArgumentException("No content with that id.", nameof(parentId)); @@ -229,26 +303,34 @@ public IContent Create(string name, int parentId, IContentType contentType, } /// - /// Creates an object of a specified content type, under a parent. + /// Creates an object of a specified content type, under a parent. /// - /// This method simply returns a new, non-persisted, IContent without any identity. It - /// is intended as a shortcut to creating new content objects that does not invoke a save - /// operation against the database. + /// + /// This method simply returns a new, non-persisted, IContent without any identity. It + /// is intended as a shortcut to creating new content objects that does not invoke a save + /// operation against the database. /// /// The name of the content object. /// The parent content object. /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. - public IContent Create(string name, IContent parent, string contentTypeAlias, int userId = Cms.Core.Constants.Security.SuperUserId) + public IContent Create(string name, IContent parent, string contentTypeAlias, + int userId = Constants.Security.SuperUserId) { // TODO: what about culture? - if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } - var contentType = GetContentType(contentTypeAlias); + IContentType contentType = GetContentType(contentTypeAlias); if (contentType == null) - throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback + { + throw new ArgumentException("No content type with that alias.", + nameof(contentTypeAlias)); // causes rollback + } var content = new Content(name, parent, contentType, userId); @@ -256,7 +338,7 @@ public IContent Create(string name, IContent parent, string contentTypeAlias, in } /// - /// Creates an object of a specified content type. + /// Creates an object of a specified content type. /// /// This method returns a new, persisted, IContent with an identity. /// The name of the content object. @@ -264,24 +346,32 @@ public IContent Create(string name, IContent parent, string contentTypeAlias, in /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. - public IContent CreateAndSave(string name, int parentId, string contentTypeAlias, int userId = Cms.Core.Constants.Security.SuperUserId) + public IContent CreateAndSave(string name, int parentId, string contentTypeAlias, + int userId = Constants.Security.SuperUserId) { // TODO: what about culture? - using (var scope = ScopeProvider.CreateScope(autoComplete:true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { // locking the content tree secures content types too - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - var contentType = GetContentType(contentTypeAlias); // + locks + IContentType contentType = GetContentType(contentTypeAlias); // + locks if (contentType == null) - throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback + { + throw new ArgumentException("No content type with that alias.", + nameof(contentTypeAlias)); // causes rollback + } - var parent = parentId > 0 ? GetById(parentId) : null; // + locks + IContent parent = parentId > 0 ? GetById(parentId) : null; // + locks if (parentId > 0 && parent == null) + { throw new ArgumentException("No content with that id.", nameof(parentId)); // causes rollback + } - var content = parentId > 0 ? new Content(name, parent, contentType, userId) : new Content(name, parentId, contentType, userId); + Content content = parentId > 0 + ? new Content(name, parent, contentType, userId) + : new Content(name, parentId, contentType, userId); Save(content, userId); @@ -290,7 +380,7 @@ public IContent CreateAndSave(string name, int parentId, string contentTypeAlias } /// - /// Creates an object of a specified content type, under a parent. + /// Creates an object of a specified content type, under a parent. /// /// This method returns a new, persisted, IContent with an identity. /// The name of the content object. @@ -298,20 +388,27 @@ public IContent CreateAndSave(string name, int parentId, string contentTypeAlias /// The alias of the content type. /// The optional id of the user creating the content. /// The content object. - public IContent CreateAndSave(string name, IContent parent, string contentTypeAlias, int userId = Cms.Core.Constants.Security.SuperUserId) + public IContent CreateAndSave(string name, IContent parent, string contentTypeAlias, + int userId = Constants.Security.SuperUserId) { // TODO: what about culture? - if (parent == null) throw new ArgumentNullException(nameof(parent)); + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { // locking the content tree secures content types too - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - var contentType = GetContentType(contentTypeAlias); // + locks + IContentType contentType = GetContentType(contentTypeAlias); // + locks if (contentType == null) - throw new ArgumentException("No content type with that alias.", nameof(contentTypeAlias)); // causes rollback + { + throw new ArgumentException("No content type with that alias.", + nameof(contentTypeAlias)); // causes rollback + } var content = new Content(name, parent, contentType, userId); @@ -326,96 +423,120 @@ public IContent CreateAndSave(string name, IContent parent, string contentTypeAl #region Get, Has, Is /// - /// Gets an object by Id + /// Gets an object by Id /// /// Id of the Content to retrieve - /// + /// + /// + /// public IContent GetById(int id) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(id); } } /// - /// Gets an object by Id + /// Gets an object by Id /// /// Ids of the Content to retrieve - /// + /// + /// + /// public IEnumerable GetByIds(IEnumerable ids) { var idsA = ids.ToArray(); - if (idsA.Length == 0) return Enumerable.Empty(); + if (idsA.Length == 0) + { + return Enumerable.Empty(); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var items = _documentRepository.GetMany(idsA); + scope.ReadLock(Constants.Locks.ContentTree); + IEnumerable items = _documentRepository.GetMany(idsA); var index = items.ToDictionary(x => x.Id, x => x); - return idsA.Select(x => index.TryGetValue(x, out var c) ? c : null).WhereNotNull(); + return idsA.Select(x => index.TryGetValue(x, out IContent c) ? c : null).WhereNotNull(); } } /// - /// Gets an object by its 'UniqueId' + /// Gets an object by its 'UniqueId' /// /// Guid key of the Content to retrieve - /// + /// + /// + /// public IContent GetById(Guid key) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(key); } } /// - /// /// /// /// /// - Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => Attempt.Succeed(Save(contents, userId)); + Attempt IContentServiceBase.Save(IEnumerable contents, int userId) => + Attempt.Succeed(Save(contents, userId)); /// - /// Gets objects by Ids + /// Gets objects by Ids /// /// Ids of the Content to retrieve - /// + /// + /// + /// public IEnumerable GetByIds(IEnumerable ids) { - var idsA = ids.ToArray(); - if (idsA.Length == 0) return Enumerable.Empty(); + Guid[] idsA = ids.ToArray(); + if (idsA.Length == 0) + { + return Enumerable.Empty(); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var items = _documentRepository.GetMany(idsA); + scope.ReadLock(Constants.Locks.ContentTree); + IEnumerable items = _documentRepository.GetMany(idsA); var index = items.ToDictionary(x => x.Key, x => x); - return idsA.Select(x => index.TryGetValue(x, out var c) ? c : null).WhereNotNull(); + return idsA.Select(x => index.TryGetValue(x, out IContent c) ? c : null).WhereNotNull(); } } /// - public IEnumerable GetPagedOfType(int contentTypeId, long pageIndex, int pageSize, out long totalRecords + public IEnumerable GetPagedOfType(int contentTypeId, long pageIndex, int pageSize, + out long totalRecords , IQuery filter = null, Ordering ordering = null) { - if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); - if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } if (ordering == null) + { ordering = Ordering.By("sortOrder"); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetPage( Query().Where(x => x.ContentTypeId == contentTypeId), pageIndex, pageSize, out totalRecords, filter, ordering); @@ -423,17 +544,27 @@ public IEnumerable GetPagedOfType(int contentTypeId, long pageIndex, i } /// - public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery filter, Ordering ordering = null) + public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, + out long totalRecords, IQuery filter, Ordering ordering = null) { - if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); - if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } if (ordering == null) + { ordering = Ordering.By("sortOrder"); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetPage( Query().Where(x => contentTypeIds.Contains(x.ContentTypeId)), pageIndex, pageSize, out totalRecords, filter, ordering); @@ -441,64 +572,64 @@ public IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageInde } /// - /// Gets a collection of objects by Level + /// Gets a collection of objects by Level /// /// The level to retrieve Content from - /// An Enumerable list of objects + /// An Enumerable list of objects /// Contrary to most methods, this method filters out trashed content items. public IEnumerable GetByLevel(int level) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var query = Query().Where(x => x.Level == level && x.Trashed == false); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery query = Query().Where(x => x.Level == level && x.Trashed == false); return _documentRepository.Get(query); } } /// - /// Gets a specific version of an item. + /// Gets a specific version of an item. /// /// Id of the version to retrieve - /// An item + /// An item public IContent GetVersion(int versionId) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetVersion(versionId); } } /// - /// Gets a collection of an objects versions by Id + /// Gets a collection of an objects versions by Id /// /// - /// An Enumerable list of objects + /// An Enumerable list of objects public IEnumerable GetVersions(int id) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetAllVersions(id); } } /// - /// Gets a collection of an objects versions by Id + /// Gets a collection of an objects versions by Id /// - /// An Enumerable list of objects + /// An Enumerable list of objects public IEnumerable GetVersionsSlim(int id, int skip, int take) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetAllVersionsSlim(id, skip, take); } } /// - /// Gets a list of all version Ids for the given content item ordered so latest is first + /// Gets a list of all version Ids for the given content item ordered so latest is first /// /// /// The maximum number of rows to return @@ -512,22 +643,22 @@ public IEnumerable GetVersionIds(int id, int maxRows) } /// - /// Gets a collection of objects, which are ancestors of the current content. + /// Gets a collection of objects, which are ancestors of the current content. /// - /// Id of the to retrieve ancestors for - /// An Enumerable list of objects + /// Id of the to retrieve ancestors for + /// An Enumerable list of objects public IEnumerable GetAncestors(int id) { // intentionally not locking - var content = GetById(id); + IContent content = GetById(id); return GetAncestors(content); } /// - /// Gets a collection of objects, which are ancestors of the current content. + /// Gets a collection of objects, which are ancestors of the current content. /// - /// to retrieve ancestors for - /// An Enumerable list of objects + /// to retrieve ancestors for + /// An Enumerable list of objects public IEnumerable GetAncestors(IContent content) { //null check otherwise we get exceptions @@ -550,16 +681,16 @@ public IEnumerable GetAncestors(IContent content) } /// - /// Gets a collection of published objects by Parent Id + /// Gets a collection of published objects by Parent Id /// /// Id of the Parent to retrieve Children from - /// An Enumerable list of published objects + /// An Enumerable list of published objects public IEnumerable GetPublishedChildren(int id) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var query = Query().Where(x => x.ParentId == id && x.Published); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery query = Query().Where(x => x.ParentId == id && x.Published); return _documentRepository.Get(query).OrderBy(x => x.SortOrder); } } @@ -568,17 +699,26 @@ public IEnumerable GetPublishedChildren(int id) public IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalChildren, IQuery filter = null, Ordering ordering = null) { - if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); - if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } if (ordering == null) + { ordering = Ordering.By("sortOrder"); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); - var query = Query().Where(x => x.ParentId == id); + IQuery query = Query().Where(x => x.ParentId == id); return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, ordering); } } @@ -588,93 +728,116 @@ public IEnumerable GetPagedDescendants(int id, long pageIndex, int pag IQuery filter = null, Ordering ordering = null) { if (ordering == null) + { ordering = Ordering.By("Path"); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); //if the id is System Root, then just get all - if (id != Cms.Core.Constants.System.Root) + if (id != Constants.System.Root) { - var contentPath = _entityRepository.GetAllPaths(Cms.Core.Constants.ObjectTypes.Document, id).ToArray(); + TreeEntityPath[] contentPath = + _entityRepository.GetAllPaths(Constants.ObjectTypes.Document, id).ToArray(); if (contentPath.Length == 0) { totalChildren = 0; return Enumerable.Empty(); } - return GetPagedLocked(GetPagedDescendantQuery(contentPath[0].Path), pageIndex, pageSize, out totalChildren, filter, ordering); + + return GetPagedLocked(GetPagedDescendantQuery(contentPath[0].Path), pageIndex, pageSize, + out totalChildren, filter, ordering); } + return GetPagedLocked(null, pageIndex, pageSize, out totalChildren, filter, ordering); } } private IQuery GetPagedDescendantQuery(string contentPath) { - var query = Query(); + IQuery query = Query(); if (!contentPath.IsNullOrWhiteSpace()) + { query.Where(x => x.Path.SqlStartsWith($"{contentPath},", TextColumnType.NVarchar)); + } + return query; } - private IEnumerable GetPagedLocked(IQuery query, long pageIndex, int pageSize, out long totalChildren, + private IEnumerable GetPagedLocked(IQuery query, long pageIndex, int pageSize, + out long totalChildren, IQuery filter, Ordering ordering) { - if (pageIndex < 0) throw new ArgumentOutOfRangeException(nameof(pageIndex)); - if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize)); - if (ordering == null) throw new ArgumentNullException(nameof(ordering)); + if (pageIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(pageIndex)); + } + + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + if (ordering == null) + { + throw new ArgumentNullException(nameof(ordering)); + } return _documentRepository.GetPage(query, pageIndex, pageSize, out totalChildren, filter, ordering); } /// - /// Gets the parent of the current content as an item. + /// Gets the parent of the current content as an item. /// - /// Id of the to retrieve the parent from - /// Parent object + /// Id of the to retrieve the parent from + /// Parent object public IContent GetParent(int id) { // intentionally not locking - var content = GetById(id); + IContent content = GetById(id); return GetParent(content); } /// - /// Gets the parent of the current content as an item. + /// Gets the parent of the current content as an item. /// - /// to retrieve the parent from - /// Parent object + /// to retrieve the parent from + /// Parent object public IContent GetParent(IContent content) { - if (content.ParentId == Cms.Core.Constants.System.Root || content.ParentId == Cms.Core.Constants.System.RecycleBinContent) + if (content.ParentId == Constants.System.Root || content.ParentId == Constants.System.RecycleBinContent) + { return null; + } return GetById(content.ParentId); } /// - /// Gets a collection of objects, which reside at the first level / root + /// Gets a collection of objects, which reside at the first level / root /// - /// An Enumerable list of objects + /// An Enumerable list of objects public IEnumerable GetRootContent() { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var query = Query().Where(x => x.ParentId == Cms.Core.Constants.System.Root); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery query = Query().Where(x => x.ParentId == Constants.System.Root); return _documentRepository.Get(query); } } /// - /// Gets all published content items + /// Gets all published content items /// /// internal IEnumerable GetAllPublished() { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.Get(QueryNotTrashed); } } @@ -682,9 +845,9 @@ internal IEnumerable GetAllPublished() /// public IEnumerable GetContentForExpiration(DateTime date) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetContentForExpiration(date); } } @@ -692,62 +855,69 @@ public IEnumerable GetContentForExpiration(DateTime date) /// public IEnumerable GetContentForRelease(DateTime date) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.GetContentForRelease(date); } } /// - /// Gets a collection of an objects, which resides in the Recycle Bin + /// Gets a collection of an objects, which resides in the Recycle Bin /// - /// An Enumerable list of objects + /// An Enumerable list of objects public IEnumerable GetPagedContentInRecycleBin(long pageIndex, int pageSize, out long totalRecords, IQuery filter = null, Ordering ordering = null) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { if (ordering == null) + { ordering = Ordering.By("Path"); + } - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var query = Query().Where(x => x.Path.StartsWith(Cms.Core.Constants.System.RecycleBinContentPathPrefix)); + scope.ReadLock(Constants.Locks.ContentTree); + IQuery query = Query() + .Where(x => x.Path.StartsWith(Constants.System.RecycleBinContentPathPrefix)); return _documentRepository.GetPage(query, pageIndex, pageSize, out totalRecords, filter, ordering); } } /// - /// Checks whether an item has any children + /// Checks whether an item has any children /// - /// Id of the + /// Id of the /// True if the content has any children otherwise False - public bool HasChildren(int id) - { - return CountChildren(id) > 0; - } + public bool HasChildren(int id) => CountChildren(id) > 0; /// - /// Checks if the passed in can be published based on the ancestors publish state. + /// Checks if the passed in can be published based on the ancestors publish state. /// - /// to check if ancestors are published + /// to check if ancestors are published /// True if the Content can be published, otherwise False public bool IsPathPublishable(IContent content) { // fast - if (content.ParentId == Cms.Core.Constants.System.Root) return true; // root content is always publishable - if (content.Trashed) return false; // trashed content is never publishable + if (content.ParentId == Constants.System.Root) + { + return true; // root content is always publishable + } + + if (content.Trashed) + { + return false; // trashed content is never publishable + } // not trashed and has a parent: publishable if the parent is path-published - var parent = GetById(content.ParentId); + IContent parent = GetById(content.ParentId); return parent == null || IsPathPublished(parent); } public bool IsPathPublished(IContent content) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.IsPathPublished(content); } } @@ -757,17 +927,19 @@ public bool IsPathPublished(IContent content) #region Save, Publish, Unpublish /// - public OperationResult Save(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult Save(IContent content, int userId = Constants.Security.SuperUserId) { PublishedState publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) { - throw new InvalidOperationException($"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); + throw new InvalidOperationException( + $"Cannot save (un)publishing content with name: {content.Name} - and state: {content.PublishedState}, use the dedicated SavePublished method."); } if (content.Name != null && content.Name.Length > 255) { - throw new InvalidOperationException($"Content with the name {content.Name} cannot be more than 255 characters in length."); + throw new InvalidOperationException( + $"Content with the name {content.Name} cannot be more than 255 characters in length."); } EventMessages eventMessages = EventMessagesFactory.Get(); @@ -801,13 +973,15 @@ public OperationResult Save(IContent content, int userId = Cms.Core.Constants.Se _documentRepository.Save(content); - scope.Notifications.Publish(new ContentSavedNotification(content, eventMessages).WithStateFrom(savingNotification)); + scope.Notifications.Publish( + new ContentSavedNotification(content, eventMessages).WithStateFrom(savingNotification)); // TODO: we had code here to FORCE that this event can never be suppressed. But that just doesn't make a ton of sense?! // I understand that if its suppressed that the caches aren't updated, but that would be expected. If someone // is supressing events then I think it's expected that nothing will happen. They are probably doing it for perf // reasons like bulk import and in those cases we don't want this occuring. - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, eventMessages)); if (culturesChanging != null) { @@ -817,7 +991,9 @@ public OperationResult Save(IContent content, int userId = Cms.Core.Constants.Se Audit(AuditType.SaveVariant, userId, content.Id, $"Saved languages: {langs}", langs); } else + { Audit(AuditType.Save, userId, content.Id); + } scope.Complete(); } @@ -826,7 +1002,7 @@ public OperationResult Save(IContent content, int userId = Cms.Core.Constants.Se } /// - public OperationResult Save(IEnumerable contents, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult Save(IEnumerable contents, int userId = Constants.Security.SuperUserId) { EventMessages eventMessages = EventMessagesFactory.Get(); IContent[] contentsA = contents.ToArray(); @@ -840,7 +1016,7 @@ public OperationResult Save(IEnumerable contents, int userId = Cms.Cor return OperationResult.Cancel(eventMessages); } - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); foreach (IContent content in contentsA) { if (content.HasIdentity == false) @@ -853,11 +1029,13 @@ public OperationResult Save(IEnumerable contents, int userId = Cms.Cor _documentRepository.Save(content); } - scope.Notifications.Publish(new ContentSavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); + scope.Notifications.Publish( + new ContentSavedNotification(contentsA, eventMessages).WithStateFrom(savingNotification)); // TODO: See note above about supressing events - scope.Notifications.Publish(new ContentTreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); - Audit(AuditType.Save, userId == -1 ? 0 : userId, Cms.Core.Constants.System.Root, "Saved multiple content"); + Audit(AuditType.Save, userId == -1 ? 0 : userId, Constants.System.Root, "Saved multiple content"); scope.Complete(); } @@ -866,25 +1044,34 @@ public OperationResult Save(IEnumerable contents, int userId = Cms.Cor } /// - public PublishResult SaveAndPublish(IContent content, string culture = "*", int userId = Cms.Core.Constants.Security.SuperUserId) + public PublishResult SaveAndPublish(IContent content, string culture = "*", + int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - var publishedState = content.PublishedState; + PublishedState publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - throw new InvalidOperationException($"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + { + throw new InvalidOperationException( + $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + } // cannot accept invariant (null or empty) culture for variant content type // cannot accept a specific culture for invariant content type (but '*' is ok) if (content.ContentType.VariesByCulture()) { if (culture.IsNullOrWhiteSpace()) + { throw new NotSupportedException("Invariant culture is not supported by variant content types."); + } } else { if (!culture.IsNullOrWhiteSpace() && culture != "*") - throw new NotSupportedException($"Culture \"{culture}\" is not supported by invariant content types."); + { + throw new NotSupportedException( + $"Culture \"{culture}\" is not supported by invariant content types."); + } } if (content.Name != null && content.Name.Length > 255) @@ -892,9 +1079,9 @@ public PublishResult SaveAndPublish(IContent content, string culture = "*", int throw new InvalidOperationException("Name cannot be more than 255 characters in length."); } - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var allLangs = _languageRepository.GetMany().ToList(); @@ -914,30 +1101,39 @@ public PublishResult SaveAndPublish(IContent content, string culture = "*", int // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. content.PublishCulture(impact); - var result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, + savingNotification.State, userId); scope.Complete(); return result; } } /// - public PublishResult SaveAndPublish(IContent content, string[] cultures, int userId = Cms.Core.Constants.Security.SuperUserId) + public PublishResult SaveAndPublish(IContent content, string[] cultures, + int userId = Constants.Security.SuperUserId) { - if (content == null) throw new ArgumentNullException(nameof(content)); - if (cultures == null) throw new ArgumentNullException(nameof(cultures)); + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } + + if (cultures == null) + { + throw new ArgumentNullException(nameof(cultures)); + } if (content.Name != null && content.Name.Length > 255) { throw new InvalidOperationException("Name cannot be more than 255 characters in length."); } - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var allLangs = _languageRepository.GetMany().ToList(); - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); var savingNotification = new ContentSavingNotification(content, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) @@ -954,56 +1150,75 @@ public PublishResult SaveAndPublish(IContent content, string[] cultures, int use } if (cultures.Any(x => x == null || x == "*")) - throw new InvalidOperationException("Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); + { + throw new InvalidOperationException( + "Only valid cultures are allowed to be used in this method, wildcards or nulls are not allowed"); + } - var impacts = cultures.Select(x => CultureImpact.Explicit(x, IsDefaultCulture(allLangs, x))); + IEnumerable impacts = + cultures.Select(x => CultureImpact.Explicit(x, IsDefaultCulture(allLangs, x))); // publish the culture(s) // we don't care about the response here, this response will be rechecked below but we need to set the culture info values now. - foreach (var impact in impacts) + foreach (CultureImpact impact in impacts) { content.PublishCulture(impact); } - var result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, + savingNotification.State, userId); scope.Complete(); return result; } } /// - public PublishResult Unpublish(IContent content, string culture = "*", int userId = Cms.Core.Constants.Security.SuperUserId) + public PublishResult Unpublish(IContent content, string culture = "*", + int userId = Constants.Security.SuperUserId) { - if (content == null) throw new ArgumentNullException(nameof(content)); + if (content == null) + { + throw new ArgumentNullException(nameof(content)); + } - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); culture = culture.NullOrWhiteSpaceAsNull(); - var publishedState = content.PublishedState; + PublishedState publishedState = content.PublishedState; if (publishedState != PublishedState.Published && publishedState != PublishedState.Unpublished) - throw new InvalidOperationException($"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + { + throw new InvalidOperationException( + $"Cannot save-and-publish (un)publishing content, use the dedicated {nameof(CommitDocumentChanges)} method."); + } // cannot accept invariant (null or empty) culture for variant content type // cannot accept a specific culture for invariant content type (but '*' is ok) if (content.ContentType.VariesByCulture()) { if (culture == null) + { throw new NotSupportedException("Invariant culture is not supported by variant content types."); + } } else { if (culture != null && culture != "*") - throw new NotSupportedException($"Culture \"{culture}\" is not supported by invariant content types."); + { + throw new NotSupportedException( + $"Culture \"{culture}\" is not supported by invariant content types."); + } } // if the content is not published, nothing to do if (!content.Published) + { return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); + } - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var allLangs = _languageRepository.GetMany().ToList(); @@ -1022,7 +1237,8 @@ public PublishResult Unpublish(IContent content, string culture = "*", int userI // to be non-routable so that when it's re-published all variants were as they were. content.PublishedState = PublishedState.Unpublishing; - var result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, + savingNotification.State, userId); scope.Complete(); return result; } @@ -1036,7 +1252,8 @@ public PublishResult Unpublish(IContent content, string culture = "*", int userI var removed = content.UnpublishCulture(culture); //save and publish any changes - var result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, + savingNotification.State, userId); scope.Complete(); @@ -1044,7 +1261,9 @@ public PublishResult Unpublish(IContent content, string culture = "*", int userI // were specified to be published which will be the case when removed is false. In that case // we want to swap the result type to PublishResultType.SuccessUnpublishAlready (that was the expectation before). if (result.Result == PublishResultType.FailedPublishNothingToPublish && !removed) + { return new PublishResult(PublishResultType.SuccessUnpublishAlready, evtMsgs, content); + } return result; } @@ -1052,32 +1271,39 @@ public PublishResult Unpublish(IContent content, string culture = "*", int userI } /// - /// Saves a document and publishes/unpublishes any pending publishing changes made to the document. + /// Saves a document and publishes/unpublishes any pending publishing changes made to the document. /// /// - /// - /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of this service. - /// Internally in this service, calls must be made to CommitDocumentChangesInternal - /// - /// - /// This is the underlying logic for both publishing and unpublishing any document - /// Pending publishing/unpublishing changes on a document are made with calls to and - /// . - /// When publishing or unpublishing a single culture, or all cultures, use - /// and . But if the flexibility to both publish and unpublish in a single operation is required - /// then this method needs to be used in combination with and - /// on the content itself - this prepares the content, but does not commit anything - and then, invoke - /// to actually commit the changes to the database. - /// The document is *always* saved, even when publishing fails. + /// + /// This MUST NOT be called from within this service, this used to be a public API and must only be used outside of + /// this service. + /// Internally in this service, calls must be made to CommitDocumentChangesInternal + /// + /// This is the underlying logic for both publishing and unpublishing any document + /// + /// Pending publishing/unpublishing changes on a document are made with calls to + /// and + /// . + /// + /// + /// When publishing or unpublishing a single culture, or all cultures, use + /// and . But if the flexibility to both publish and unpublish in a single operation is + /// required + /// then this method needs to be used in combination with + /// and + /// on the content itself - this prepares the content, but does not commit anything - and then, invoke + /// to actually commit the changes to the database. + /// + /// The document is *always* saved, even when publishing fails. /// internal PublishResult CommitDocumentChanges(IContent content, - int userId = Cms.Core.Constants.Security.SuperUserId) + int userId = Constants.Security.SuperUserId) { - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var savingNotification = new ContentSavingNotification(content, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) @@ -1087,14 +1313,15 @@ internal PublishResult CommitDocumentChanges(IContent content, var allLangs = _languageRepository.GetMany().ToList(); - var result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, savingNotification.State, userId); + PublishResult result = CommitDocumentChangesInternal(scope, content, evtMsgs, allLangs, + savingNotification.State, userId); scope.Complete(); return result; } } /// - /// Handles a lot of business logic cases for how the document should be persisted + /// Handles a lot of business logic cases for how the document should be persisted /// /// /// @@ -1106,10 +1333,12 @@ internal PublishResult CommitDocumentChanges(IContent content, /// /// /// - /// - /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for pending scheduled publishing, etc... is dealt with in this method. - /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled saving/publishing, branch saving/publishing, etc... - /// + /// + /// Business logic cases such: as unpublishing a mandatory culture, or unpublishing the last culture, checking for + /// pending scheduled publishing, etc... is dealt with in this method. + /// There is quite a lot of cases to take into account along with logic that needs to deal with scheduled + /// saving/publishing, branch saving/publishing, etc... + /// /// private PublishResult CommitDocumentChangesInternal(IScope scope, IContent content, EventMessages eventMessages, IReadOnlyCollection allLangs, @@ -1136,7 +1365,8 @@ private PublishResult CommitDocumentChangesInternal(IScope scope, IContent conte PublishResult unpublishResult = null; // nothing set = republish it all - if (content.PublishedState != PublishedState.Publishing && content.PublishedState != PublishedState.Unpublishing) + if (content.PublishedState != PublishedState.Publishing && + content.PublishedState != PublishedState.Unpublishing) { content.PublishedState = PublishedState.Publishing; } @@ -1179,18 +1409,20 @@ void SaveDocument(IContent c) //determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo culturesUnpublishing = content.GetCulturesUnpublishing(); culturesPublishing = variesByCulture - ? content.PublishCultureInfos.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() - : null; + ? content.PublishCultureInfos.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList() + : null; // ensure that the document can be published, and publish handling events, business rules, etc - publishResult = StrategyCanPublish(scope, content, /*checkPath:*/ (!branchOne || branchRoot), culturesPublishing, culturesUnpublishing, eventMessages, allLangs, notificationState); + publishResult = StrategyCanPublish(scope, content, /*checkPath:*/ !branchOne || branchRoot, + culturesPublishing, culturesUnpublishing, eventMessages, allLangs, notificationState); if (publishResult.Success) { // note: StrategyPublish flips the PublishedState to Publishing! publishResult = StrategyPublish(content, culturesPublishing, culturesUnpublishing, eventMessages); //check if a culture has been unpublished and if there are no cultures left, and then unpublish document as a whole - if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && content.PublishCultureInfos.Count == 0) + if (publishResult.Result == PublishResultType.SuccessUnpublishCulture && + content.PublishCultureInfos.Count == 0) { // This is a special case! We are unpublishing the last culture and to persist that we need to re-publish without any cultures // so the state needs to remain Publishing to do that. However, we then also need to unpublish the document and to do that @@ -1235,7 +1467,8 @@ void SaveDocument(IContent c) IContent newest = GetById(content.Id); // ensure we have the newest version - in scope if (content.VersionId != newest.VersionId) { - return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, content); + return new PublishResult(PublishResultType.FailedPublishConcurrencyViolation, eventMessages, + content); } if (content.Published) @@ -1272,27 +1505,32 @@ void SaveDocument(IContent c) SaveDocument(content); // raise the Saved event, always - scope.Notifications.Publish(new ContentSavedNotification(content, eventMessages).WithState(notificationState)); + scope.Notifications.Publish( + new ContentSavedNotification(content, eventMessages).WithState(notificationState)); if (unpublishing) // we have tried to unpublish - won't happen in a branch { if (unpublishResult.Success) // and succeeded, trigger events { // events and audit - scope.Notifications.Publish(new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); + scope.Notifications.Publish( + new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState)); + scope.Notifications.Publish(new ContentTreeChangeNotification(content, + TreeChangeTypes.RefreshBranch, eventMessages)); if (culturesUnpublishing != null) { // This will mean that that we unpublished a mandatory culture or we unpublished the last culture. var langs = string.Join(", ", allLangs - .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) - .Select(x => x.CultureName)); + .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) + .Select(x => x.CultureName)); Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); if (publishResult == null) + { throw new PanicException("publishResult == null - should not happen"); + } switch (publishResult.Result) { @@ -1300,15 +1538,18 @@ void SaveDocument(IContent c) //occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture) //log that the whole content item has been unpublished due to mandatory culture unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (mandatory language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, eventMessages, content); + Audit(AuditType.Unpublish, userId, content.Id, + "Unpublished (mandatory language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishMandatoryCulture, + eventMessages, content); case PublishResultType.SuccessUnpublishCulture: //occurs when the last culture is unpublished - Audit(AuditType.Unpublish, userId, content.Id, "Unpublished (last language unpublished)"); - return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, content); + Audit(AuditType.Unpublish, userId, content.Id, + "Unpublished (last language unpublished)"); + return new PublishResult(PublishResultType.SuccessUnpublishLastCulture, eventMessages, + content); } - } Audit(AuditType.Unpublish, userId, content.Id); @@ -1337,8 +1578,10 @@ void SaveDocument(IContent c) // invalidate the node/branch if (!branchOne) // for branches, handled by SaveAndPublishBranch { - scope.Notifications.Publish(new ContentTreeChangeNotification(content, changeType, eventMessages)); - scope.Notifications.Publish(new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(content, changeType, eventMessages)); + scope.Notifications.Publish( + new ContentPublishedNotification(content, eventMessages).WithState(notificationState)); } // it was not published and now is... descendants that were 'published' (but @@ -1347,7 +1590,8 @@ void SaveDocument(IContent c) if (!branchOne && isNew == false && previouslyPublished == false && HasChildren(content.Id)) { IContent[] descendants = GetPublishedDescendantsLocked(content).ToArray(); - scope.Notifications.Publish(new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState)); + scope.Notifications.Publish( + new ContentPublishedNotification(descendants, eventMessages).WithState(notificationState)); } switch (publishResult.Result) @@ -1361,17 +1605,21 @@ void SaveDocument(IContent c) var langs = string.Join(", ", allLangs .Where(x => culturesPublishing.InvariantContains(x.IsoCode)) .Select(x => x.CultureName)); - Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", langs); + Audit(AuditType.PublishVariant, userId, content.Id, $"Published languages: {langs}", + langs); } + break; case PublishResultType.SuccessUnpublishCulture: if (culturesUnpublishing != null) { var langs = string.Join(", ", allLangs - .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) - .Select(x => x.CultureName)); - Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs); + .Where(x => culturesUnpublishing.InvariantContains(x.IsoCode)) + .Select(x => x.CultureName)); + Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", + langs); } + break; } @@ -1410,7 +1658,7 @@ void SaveDocument(IContent c) public IEnumerable PerformScheduledPublish(DateTime date) { var allLangs = new Lazy>(() => _languageRepository.GetMany().ToList()); - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); var results = new List(); PerformScheduledPublishingRelease(date, results, evtMsgs, allLangs); @@ -1419,17 +1667,18 @@ public IEnumerable PerformScheduledPublish(DateTime date) return results; } - private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + private void PerformScheduledPublishingExpiration(DateTime date, List results, + EventMessages evtMsgs, Lazy> allLangs) { - using var scope = ScopeProvider.CreateScope(); + using IScope scope = ScopeProvider.CreateScope(); // do a fast read without any locks since this executes often to see if we even need to proceed if (_documentRepository.HasContentForExpiration(date)) { // now take a write lock since we'll be updating - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - foreach (var d in _documentRepository.GetContentForExpiration(date)) + foreach (IContent d in _documentRepository.GetContentForExpiration(date)) { if (d.ContentType.VariesByCulture()) { @@ -1440,7 +1689,9 @@ private void PerformScheduledPublishingExpiration(DateTime date, List results, EventMessages evtMsgs, Lazy> allLangs) + private void PerformScheduledPublishingRelease(DateTime date, List results, + EventMessages evtMsgs, Lazy> allLangs) { - using var scope = ScopeProvider.CreateScope(); + using IScope scope = ScopeProvider.CreateScope(); // do a fast read without any locks since this executes often to see if we even need to proceed if (_documentRepository.HasContentForRelease(date)) { // now take a write lock since we'll be updating - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - foreach (var d in _documentRepository.GetContentForRelease(date)) + foreach (IContent d in _documentRepository.GetContentForRelease(date)) { if (d.ContentType.VariesByCulture()) { @@ -1501,7 +1761,9 @@ private void PerformScheduledPublishingRelease(DateTime date, List 0) - _logger.LogWarning("Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", + { + _logger.LogWarning( + "Scheduled publishing will fail for document {DocumentId} and culture {Culture} because of invalid properties {InvalidProperties}", d.Id, culture, string.Join(",", invalidProperties.Select(x => x.Alias))); + } publishing &= tryPublish; //set the culture to be published - if (!publishing) continue; // move to next document + if (!publishing) + { + } } PublishResult result; if (d.Trashed) + { result = new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, d); + } else if (!publishing) + { result = new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, d); + } else - result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, savingNotification.State, d.WriterId); + { + result = CommitDocumentChangesInternal(scope, d, evtMsgs, allLangs.Value, + savingNotification.State, d.WriterId); + } if (result.Success == false) - _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, result.Result); + { + _logger.LogError(null, "Failed to publish document id={DocumentId}, reason={Reason}.", d.Id, + result.Result); + } results.Add(result); } @@ -1549,26 +1831,29 @@ private void PerformScheduledPublishingRelease(DateTime date, List culturesToPublish, IReadOnlyCollection allLangs) + private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet culturesToPublish, + IReadOnlyCollection allLangs) { //TODO: This does not support being able to return invalid property details to bubble up to the UI @@ -1579,7 +1864,8 @@ private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet { var impact = CultureImpact.Create(culture, IsDefaultCulture(allLangs, culture), content); - return content.PublishCulture(impact) && _propertyValidationService.Value.IsPropertyDataValid(content, out _, impact); + return content.PublishCulture(impact) && + _propertyValidationService.Value.IsPropertyDataValid(content, out _, impact); }); } @@ -1588,26 +1874,43 @@ private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet SaveAndPublishBranch_ShouldPublish(ref HashSet cultures, string c, bool published, bool edited, bool isRoot, bool force) + private HashSet SaveAndPublishBranch_ShouldPublish(ref HashSet cultures, string c, + bool published, bool edited, bool isRoot, bool force) { // if published, republish if (published) { - if (cultures == null) cultures = new HashSet(); // empty means 'already published' - if (edited) cultures.Add(c); // means 'republish this culture' + if (cultures == null) + { + cultures = new HashSet(); // empty means 'already published' + } + + if (edited) + { + cultures.Add(c); // means 'republish this culture' + } + return cultures; } // if not published, publish if force/root else do nothing - if (!force && !isRoot) return cultures; // null means 'nothing to do' + if (!force && !isRoot) + { + return cultures; // null means 'nothing to do' + } + + if (cultures == null) + { + cultures = new HashSet(); + } - if (cultures == null) cultures = new HashSet(); cultures.Add(c); // means 'publish this culture' return cultures; } /// - public IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Cms.Core.Constants.Security.SuperUserId) + public IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", + int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -1624,10 +1927,16 @@ HashSet ShouldPublish(IContent c) HashSet culturesToPublish = null; if (!c.ContentType.VariesByCulture()) // invariant content type - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + { + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, + force); + } if (culture != "*") // variant content type, specific culture - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force); + { + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, + c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force); + } // variant content type, all cultures if (c.Published) @@ -1635,7 +1944,11 @@ HashSet ShouldPublish(IContent c) // then some (and maybe all) cultures will be 'already published' (unless forcing), // others will have to 'republish this culture' foreach (var x in c.AvailableCultures) - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + { + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), + c.IsCultureEdited(x), isRoot, force); + } + return culturesToPublish; } @@ -1649,7 +1962,8 @@ HashSet ShouldPublish(IContent c) } /// - public IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Cms.Core.Constants.Security.SuperUserId) + public IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, + int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -1664,7 +1978,10 @@ HashSet ShouldPublish(IContent c) HashSet culturesToPublish = null; if (!c.ContentType.VariesByCulture()) // invariant content type - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + { + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, + force); + } // variant content type, specific cultures if (c.Published) @@ -1672,7 +1989,11 @@ HashSet ShouldPublish(IContent c) // then some (and maybe all) cultures will be 'already published' (unless forcing), // others will have to 'republish this culture' foreach (var x in cultures) - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + { + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), + c.IsCultureEdited(x), isRoot, force); + } + return culturesToPublish; } @@ -1688,7 +2009,7 @@ HashSet ShouldPublish(IContent c) internal IEnumerable SaveAndPublishBranch(IContent document, bool force, Func> shouldPublish, Func, IReadOnlyCollection, bool> publishCultures, - int userId = Cms.Core.Constants.Security.SuperUserId) + int userId = Constants.Security.SuperUserId) { if (shouldPublish == null) { @@ -1706,7 +2027,7 @@ internal IEnumerable SaveAndPublishBranch(IContent document, bool using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var allLangs = _languageRepository.GetMany().ToList(); @@ -1722,7 +2043,8 @@ internal IEnumerable SaveAndPublishBranch(IContent document, bool } // deal with the branch root - if it fails, abort - PublishResult result = SaveAndPublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs); + PublishResult result = SaveAndPublishBranchItem(scope, document, shouldPublish, publishCultures, true, + publishedDocuments, eventMessages, userId, allLangs); if (result != null) { results.Add(result); @@ -1744,7 +2066,8 @@ internal IEnumerable SaveAndPublishBranch(IContent document, bool count = 0; // important to order by Path ASC so make it explicit in case defaults change // ReSharper disable once RedundantArgumentDefaultValue - foreach (IContent d in GetPagedDescendants(document.Id, page, pageSize, out _, ordering: Ordering.By("Path", Direction.Ascending))) + foreach (IContent d in GetPagedDescendants(document.Id, page, pageSize, out _, + ordering: Ordering.By("Path", Direction.Ascending))) { count++; @@ -1756,7 +2079,8 @@ internal IEnumerable SaveAndPublishBranch(IContent document, bool } // no need to check path here, parent has to be published here - result = SaveAndPublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs); + result = SaveAndPublishBranchItem(scope, d, shouldPublish, publishCultures, false, + publishedDocuments, eventMessages, userId, allLangs); if (result != null) { results.Add(result); @@ -1777,7 +2101,8 @@ internal IEnumerable SaveAndPublishBranch(IContent document, bool // trigger events for the entire branch // (SaveAndPublishBranchOne does *not* do it) - scope.Notifications.Publish(new ContentTreeChangeNotification(document, TreeChangeTypes.RefreshBranch, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(document, TreeChangeTypes.RefreshBranch, eventMessages)); scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages)); scope.Complete(); @@ -1796,11 +2121,16 @@ private PublishResult SaveAndPublishBranchItem(IScope scope, IContent document, ICollection publishedDocuments, EventMessages evtMsgs, int userId, IReadOnlyCollection allLangs) { - var culturesToPublish = shouldPublish(document); + HashSet culturesToPublish = shouldPublish(document); if (culturesToPublish == null) // null = do not include + { return null; + } + if (culturesToPublish.Count == 0) // empty = already published + { return new PublishResult(PublishResultType.SuccessPublishAlready, evtMsgs, document); + } var savingNotification = new ContentSavingNotification(document, evtMsgs); if (scope.Notifications.PublishCancelable(savingNotification)) @@ -1815,9 +2145,13 @@ private PublishResult SaveAndPublishBranchItem(IScope scope, IContent document, return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, document); } - var result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, savingNotification.State, userId, branchOne: true, branchRoot: isRoot); + PublishResult result = CommitDocumentChangesInternal(scope, document, evtMsgs, allLangs, + savingNotification.State, userId, true, isRoot); if (result.Success) + { publishedDocuments.Add(document); + } + return result; } @@ -1826,7 +2160,7 @@ private PublishResult SaveAndPublishBranchItem(IScope scope, IContent document, #region Delete /// - public OperationResult Delete(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult Delete(IContent content, int userId = Constants.Security.SuperUserId) { EventMessages eventMessages = EventMessagesFactory.Get(); @@ -1838,7 +2172,7 @@ public OperationResult Delete(IContent content, int userId = Cms.Core.Constants. return OperationResult.Cancel(eventMessages); } - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); // if it's not trashed yet, and published, we should unpublish // but... Unpublishing event makes no sense (not going to cancel?) and no need to save @@ -1850,7 +2184,8 @@ public OperationResult Delete(IContent content, int userId = Cms.Core.Constants. DeleteLocked(scope, content, eventMessages); - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, eventMessages)); Audit(AuditType.Delete, userId, content.Id); scope.Complete(); @@ -1874,10 +2209,14 @@ void DoDelete(IContent c) while (total > 0) { //get descendants - ordered from deepest to shallowest - var descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, ordering: Ordering.By("Path", Direction.Descending)); - foreach (var c in descendants) + IEnumerable descendants = GetPagedDescendants(content.Id, 0, pageSize, out total, + ordering: Ordering.By("Path", Direction.Descending)); + foreach (IContent c in descendants) + { DoDelete(c); + } } + DoDelete(content); } @@ -1887,50 +2226,54 @@ void DoDelete(IContent c) // the version referencing the file will not be there anymore. SO, we can leak files. /// - /// Permanently deletes versions from an object prior to a specific date. - /// This method will never delete the latest version of a content item. + /// Permanently deletes versions from an object prior to a specific date. + /// This method will never delete the latest version of a content item. /// - /// Id of the object to delete versions from + /// Id of the object to delete versions from /// Latest version date /// Optional Id of the User deleting versions of a Content object - public void DeleteVersions(int id, DateTime versionDate, int userId = Cms.Core.Constants.Security.SuperUserId) + public void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); + var deletingVersionsNotification = + new ContentDeletingVersionsNotification(id, evtMsgs, dateToRetain: versionDate); if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) { scope.Complete(); return; } - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); _documentRepository.DeleteVersions(id, versionDate); - scope.Notifications.Publish(new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom(deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Cms.Core.Constants.System.Root, "Delete (by version date)"); + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, dateToRetain: versionDate).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version date)"); scope.Complete(); } } /// - /// Permanently deletes specific version(s) from an object. - /// This method will never delete the latest version of a content item. + /// Permanently deletes specific version(s) from an object. + /// This method will never delete the latest version of a content item. /// - /// Id of the object to delete a version from + /// Id of the object to delete a version from /// Id of the version to delete /// Boolean indicating whether to delete versions prior to the versionId /// Optional Id of the User deleting versions of a Content object - public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Cms.Core.Constants.Security.SuperUserId) + public void DeleteVersion(int id, int versionId, bool deletePriorVersions, + int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, specificVersion: versionId); + var deletingVersionsNotification = new ContentDeletingVersionsNotification(id, evtMsgs, versionId); if (scope.Notifications.PublishCancelable(deletingVersionsNotification)) { scope.Complete(); @@ -1939,17 +2282,22 @@ public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int u if (deletePriorVersions) { - var content = GetVersion(versionId); + IContent content = GetVersion(versionId); DeleteVersions(id, content.UpdateDate, userId); } - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); - var c = _documentRepository.Get(id); - if (c.VersionId != versionId && c.PublishedVersionId != versionId) // don't delete the current or published version + scope.WriteLock(Constants.Locks.ContentTree); + IContent c = _documentRepository.Get(id); + if (c.VersionId != versionId && + c.PublishedVersionId != versionId) // don't delete the current or published version + { _documentRepository.DeleteVersion(versionId); + } - scope.Notifications.Publish(new ContentDeletedVersionsNotification(id, evtMsgs, specificVersion: versionId).WithStateFrom(deletingVersionsNotification)); - Audit(AuditType.Delete, userId, Cms.Core.Constants.System.Root, "Delete (by version)"); + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(id, evtMsgs, versionId).WithStateFrom( + deletingVersionsNotification)); + Audit(AuditType.Delete, userId, Constants.System.Root, "Delete (by version)"); scope.Complete(); } @@ -1960,19 +2308,21 @@ public void DeleteVersion(int id, int versionId, bool deletePriorVersions, int u #region Move, RecycleBin /// - public OperationResult MoveToRecycleBin(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult MoveToRecycleBin(IContent content, int userId = Constants.Security.SuperUserId) { EventMessages eventMessages = EventMessagesFactory.Get(); var moves = new List<(IContent, string)>(); using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var originalPath = content.Path; - var moveEventInfo = new MoveEventInfo(content, originalPath, Cms.Core.Constants.System.RecycleBinContent); + var moveEventInfo = + new MoveEventInfo(content, originalPath, Constants.System.RecycleBinContent); - var movingToRecycleBinNotification = new ContentMovingToRecycleBinNotification(moveEventInfo, eventMessages); + var movingToRecycleBinNotification = + new ContentMovingToRecycleBinNotification(moveEventInfo, eventMessages); if (scope.Notifications.PublishCancelable(movingToRecycleBinNotification)) { scope.Complete(); @@ -1985,14 +2335,17 @@ public OperationResult MoveToRecycleBin(IContent content, int userId = Cms.Core. //if (content.HasPublishedVersion) //{ } - PerformMoveLocked(content, Cms.Core.Constants.System.RecycleBinContent, null, userId, moves, true); - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); + PerformMoveLocked(content, Constants.System.RecycleBinContent, null, userId, moves, true); + scope.Notifications.Publish( + new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); MoveEventInfo[] moveInfo = moves .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) .ToArray(); - scope.Notifications.Publish(new ContentMovedToRecycleBinNotification(moveInfo, eventMessages).WithStateFrom(movingToRecycleBinNotification)); + scope.Notifications.Publish( + new ContentMovedToRecycleBinNotification(moveInfo, eventMessages).WithStateFrom( + movingToRecycleBinNotification)); Audit(AuditType.Move, userId, content.Id, "Moved to recycle bin"); scope.Complete(); @@ -2002,20 +2355,20 @@ public OperationResult MoveToRecycleBin(IContent content, int userId = Cms.Core. } /// - /// Moves an object to a new location by changing its parent id. + /// Moves an object to a new location by changing its parent id. /// /// - /// If the object is already published it will be - /// published after being moved to its new location. Otherwise it'll just - /// be saved with a new parent id. + /// If the object is already published it will be + /// published after being moved to its new location. Otherwise it'll just + /// be saved with a new parent id. /// - /// The to move + /// The to move /// Id of the Content's new Parent /// Optional Id of the User moving the Content - public void Move(IContent content, int parentId, int userId = Cms.Core.Constants.Security.SuperUserId) + public void Move(IContent content, int parentId, int userId = Constants.Security.SuperUserId) { // if moving to the recycle bin then use the proper method - if (parentId == Cms.Core.Constants.System.RecycleBinContent) + if (parentId == Constants.System.RecycleBinContent) { MoveToRecycleBin(content, userId); return; @@ -2027,10 +2380,10 @@ public void Move(IContent content, int parentId, int userId = Cms.Core.Constants using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - IContent parent = parentId == Cms.Core.Constants.System.Root ? null : GetById(parentId); - if (parentId != Cms.Core.Constants.System.Root && (parent == null || parent.Trashed)) + IContent parent = parentId == Constants.System.Root ? null : GetById(parentId); + if (parentId != Constants.System.Root && (parent == null || parent.Trashed)) { throw new InvalidOperationException("Parent does not exist or is trashed."); // causes rollback } @@ -2061,13 +2414,15 @@ public void Move(IContent content, int parentId, int userId = Cms.Core.Constants PerformMoveLocked(content, parentId, parent, userId, moves, trashed); - scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); MoveEventInfo[] moveInfo = moves //changes .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) .ToArray(); - scope.Notifications.Publish(new ContentMovedNotification(moveInfo, eventMessages).WithStateFrom(movingNotification)); + scope.Notifications.Publish( + new ContentMovedNotification(moveInfo, eventMessages).WithStateFrom(movingNotification)); Audit(AuditType.Move, userId, content.Id); @@ -2104,17 +2459,21 @@ private void PerformMoveLocked(IContent content, int parentId, IContent parent, // if uow is not immediate, content.Path will be updated only when the UOW commits, // and because we want it now, we have to calculate it by ourselves //paths[content.Id] = content.Path; - paths[content.Id] = (parent == null ? (parentId == Cms.Core.Constants.System.RecycleBinContent ? "-1,-20" : Cms.Core.Constants.System.RootString) : parent.Path) + "," + content.Id; + paths[content.Id] = + (parent == null + ? parentId == Constants.System.RecycleBinContent ? "-1,-20" : Constants.System.RootString + : parent.Path) + "," + content.Id; const int pageSize = 500; - var query = GetPagedDescendantQuery(originalPath); + IQuery query = GetPagedDescendantQuery(originalPath); long total; do { // We always page a page 0 because for each page, we are moving the result so the resulting total will be reduced - var descendants = GetPagedLocked(query, 0, pageSize, out total, null, Ordering.By("Path", Direction.Ascending)); + IEnumerable descendants = + GetPagedLocked(query, 0, pageSize, out total, null, Ordering.By("Path")); - foreach (var descendant in descendants) + foreach (IContent descendant in descendants) { moves.Add((descendant, descendant.Path)); // capture original path @@ -2123,32 +2482,34 @@ private void PerformMoveLocked(IContent content, int parentId, IContent parent, descendant.Level += levelDelta; PerformMoveContentLocked(descendant, userId, trash); } - } while (total > pageSize); - } private void PerformMoveContentLocked(IContent content, int userId, bool? trash) { - if (trash.HasValue) ((ContentBase)content).Trashed = trash.Value; + if (trash.HasValue) + { + ((ContentBase)content).Trashed = trash.Value; + } + content.WriterId = userId; _documentRepository.Save(content); } /// - /// Empties the Recycle Bin by deleting all that resides in the bin + /// Empties the Recycle Bin by deleting all that resides in the bin /// - public OperationResult EmptyRecycleBin(int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult EmptyRecycleBin(int userId = Constants.Security.SuperUserId) { var deleted = new List(); EventMessages eventMessages = EventMessagesFactory.Get(); using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); // emptying the recycle bin means deleting whatever is in there - do it properly! - IQuery query = Query().Where(x => x.ParentId == Cms.Core.Constants.System.RecycleBinContent); + IQuery query = Query().Where(x => x.ParentId == Constants.System.RecycleBinContent); IContent[] contents = _documentRepository.Get(query).ToArray(); var emptyingRecycleBinNotification = new ContentEmptyingRecycleBinNotification(contents, eventMessages); @@ -2164,9 +2525,12 @@ public OperationResult EmptyRecycleBin(int userId = Cms.Core.Constants.Security. deleted.Add(content); } - scope.Notifications.Publish(new ContentEmptiedRecycleBinNotification(deleted, eventMessages).WithStateFrom(emptyingRecycleBinNotification)); - scope.Notifications.Publish(new ContentTreeChangeNotification(deleted, TreeChangeTypes.Remove, eventMessages)); - Audit(AuditType.Delete, userId, Cms.Core.Constants.System.RecycleBinContent, "Recycle bin emptied"); + scope.Notifications.Publish( + new ContentEmptiedRecycleBinNotification(deleted, eventMessages).WithStateFrom( + emptyingRecycleBinNotification)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(deleted, TreeChangeTypes.Remove, eventMessages)); + Audit(AuditType.Delete, userId, Constants.System.RecycleBinContent, "Recycle bin emptied"); scope.Complete(); } @@ -2176,7 +2540,7 @@ public OperationResult EmptyRecycleBin(int userId = Cms.Core.Constants.Security. public bool RecycleBinSmells() { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { scope.ReadLock(Constants.Locks.ContentTree); return _documentRepository.RecycleBinSmells(); @@ -2188,30 +2552,31 @@ public bool RecycleBinSmells() #region Others /// - /// Copies an object by creating a new Content object of the same type and copies all data from the current - /// to the new copy which is returned. Recursively copies all children. + /// Copies an object by creating a new Content object of the same type and copies all data from + /// the current + /// to the new copy which is returned. Recursively copies all children. /// - /// The to copy + /// The to copy /// Id of the Content's new Parent /// Boolean indicating whether the copy should be related to the original /// Optional Id of the User copying the Content - /// The newly created object - public IContent Copy(IContent content, int parentId, bool relateToOriginal, int userId = Cms.Core.Constants.Security.SuperUserId) - { - return Copy(content, parentId, relateToOriginal, true, userId); - } + /// The newly created object + public IContent Copy(IContent content, int parentId, bool relateToOriginal, + int userId = Constants.Security.SuperUserId) => Copy(content, parentId, relateToOriginal, true, userId); /// - /// Copies an object by creating a new Content object of the same type and copies all data from the current - /// to the new copy which is returned. + /// Copies an object by creating a new Content object of the same type and copies all data from + /// the current + /// to the new copy which is returned. /// - /// The to copy + /// The to copy /// Id of the Content's new Parent /// Boolean indicating whether the copy should be related to the original /// A value indicating whether to recursively copy children. /// Optional Id of the User copying the Content - /// The newly created object - public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, int userId = Cms.Core.Constants.Security.SuperUserId) + /// The newly created object + public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, + int userId = Constants.Security.SuperUserId) { EventMessages eventMessages = EventMessagesFactory.Get(); @@ -2220,7 +2585,8 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool using (IScope scope = ScopeProvider.CreateScope()) { - if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(content, copy, parentId, eventMessages))) + if (scope.Notifications.PublishCancelable( + new ContentCopyingNotification(content, copy, parentId, eventMessages))) { scope.Complete(); return null; @@ -2232,7 +2598,7 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool var copies = new List>(); - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); // a copy is not published (but not really unpublishing either) // update the create author and last edit author @@ -2269,7 +2635,8 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool var total = long.MaxValue; while (page * pageSize < total) { - IEnumerable descendants = GetPagedDescendants(content.Id, page++, pageSize, out total); + IEnumerable descendants = + GetPagedDescendants(content.Id, page++, pageSize, out total); foreach (IContent descendant in descendants) { // if parent has not been copied, skip, else gets its copy id @@ -2281,7 +2648,9 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool IContent descendantCopy = descendant.DeepCloneWithResetIdentities(); descendantCopy.ParentId = parentId; - if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(descendant, descendantCopy, parentId, eventMessages))) + if (scope.Notifications.PublishCancelable( + new ContentCopyingNotification(descendant, descendantCopy, parentId, + eventMessages))) { continue; } @@ -2309,11 +2678,14 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool // - tags should be handled by the content repository // - a copy is unpublished and therefore has no impact on tags in DB - scope.Notifications.Publish(new ContentTreeChangeNotification(copy, TreeChangeTypes.RefreshBranch, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(copy, TreeChangeTypes.RefreshBranch, eventMessages)); foreach (Tuple x in copies) { - scope.Notifications.Publish(new ContentCopiedNotification(x.Item1, x.Item2, parentId, relateToOriginal, eventMessages)); + scope.Notifications.Publish(new ContentCopiedNotification(x.Item1, x.Item2, parentId, + relateToOriginal, eventMessages)); } + Audit(AuditType.Copy, userId, content.Id); scope.Complete(); @@ -2323,16 +2695,17 @@ public IContent Copy(IContent content, int parentId, bool relateToOriginal, bool } /// - /// Sends an to Publication, which executes handlers and events for the 'Send to Publication' action. + /// Sends an to Publication, which executes handlers and events for the 'Send to Publication' + /// action. /// - /// The to send to publication + /// The to send to publication /// Optional Id of the User issuing the send to publication /// True if sending publication was successful otherwise false - public bool SendToPublication(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public bool SendToPublication(IContent content, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { var sendingToPublishNotification = new ContentSendingToPublishNotification(content, evtMsgs); if (scope.Notifications.PublishCancelable(sendingToPublishNotification)) @@ -2352,77 +2725,91 @@ public bool SendToPublication(IContent content, int userId = Cms.Core.Constants. // have always changed if it's been saved in the back office but that's not really fail safe. //Save before raising event - var saveResult = Save(content, userId); + OperationResult saveResult = Save(content, userId); // always complete (but maybe return a failed status) scope.Complete(); if (!saveResult.Success) + { return saveResult.Success; + } - scope.Notifications.Publish(new ContentSentToPublishNotification(content, evtMsgs).WithStateFrom(sendingToPublishNotification)); + scope.Notifications.Publish( + new ContentSentToPublishNotification(content, evtMsgs).WithStateFrom(sendingToPublishNotification)); if (culturesChanging != null) - Audit(AuditType.SendToPublishVariant, userId, content.Id, $"Send To Publish for cultures: {culturesChanging}", culturesChanging); + { + Audit(AuditType.SendToPublishVariant, userId, content.Id, + $"Send To Publish for cultures: {culturesChanging}", culturesChanging); + } else + { Audit(AuditType.SendToPublish, content.WriterId, content.Id); + } return saveResult.Success; } } /// - /// Sorts a collection of objects by updating the SortOrder according - /// to the ordering of items in the passed in . + /// Sorts a collection of objects by updating the SortOrder according + /// to the ordering of items in the passed in . /// /// - /// Using this method will ensure that the Published-state is maintained upon sorting - /// so the cache is updated accordingly - as needed. + /// Using this method will ensure that the Published-state is maintained upon sorting + /// so the cache is updated accordingly - as needed. /// /// /// /// Result indicating what action was taken when handling the command. - public OperationResult Sort(IEnumerable items, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult Sort(IEnumerable items, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - var itemsA = items.ToArray(); - if (itemsA.Length == 0) return new OperationResult(OperationResultType.NoOperation, evtMsgs); + IContent[] itemsA = items.ToArray(); + if (itemsA.Length == 0) + { + return new OperationResult(OperationResultType.NoOperation, evtMsgs); + } - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); - var ret = Sort(scope, itemsA, userId, evtMsgs); + OperationResult ret = Sort(scope, itemsA, userId, evtMsgs); scope.Complete(); return ret; } } /// - /// Sorts a collection of objects by updating the SortOrder according - /// to the ordering of items identified by the . + /// Sorts a collection of objects by updating the SortOrder according + /// to the ordering of items identified by the . /// /// - /// Using this method will ensure that the Published-state is maintained upon sorting - /// so the cache is updated accordingly - as needed. + /// Using this method will ensure that the Published-state is maintained upon sorting + /// so the cache is updated accordingly - as needed. /// /// /// /// Result indicating what action was taken when handling the command. - public OperationResult Sort(IEnumerable ids, int userId = Cms.Core.Constants.Security.SuperUserId) + public OperationResult Sort(IEnumerable ids, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); var idsA = ids.ToArray(); - if (idsA.Length == 0) return new OperationResult(OperationResultType.NoOperation, evtMsgs); + if (idsA.Length == 0) + { + return new OperationResult(OperationResultType.NoOperation, evtMsgs); + } - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); - var itemsA = GetByIds(idsA).ToArray(); + scope.WriteLock(Constants.Locks.ContentTree); + IContent[] itemsA = GetByIds(idsA).ToArray(); - var ret = Sort(scope, itemsA, userId, evtMsgs); + OperationResult ret = Sort(scope, itemsA, userId, evtMsgs); scope.Complete(); return ret; } @@ -2476,10 +2863,13 @@ private OperationResult Sort(IScope scope, IContent[] itemsA, int userId, EventM } //first saved, then sorted - scope.Notifications.Publish(new ContentSavedNotification(itemsA, eventMessages).WithStateFrom(savingNotification)); - scope.Notifications.Publish(new ContentSortedNotification(itemsA, eventMessages).WithStateFrom(sortingNotification)); + scope.Notifications.Publish( + new ContentSavedNotification(itemsA, eventMessages).WithStateFrom(savingNotification)); + scope.Notifications.Publish( + new ContentSortedNotification(itemsA, eventMessages).WithStateFrom(sortingNotification)); - scope.Notifications.Publish(new ContentTreeChangeNotification(saved, TreeChangeTypes.RefreshNode, eventMessages)); + scope.Notifications.Publish( + new ContentTreeChangeNotification(saved, TreeChangeTypes.RefreshNode, eventMessages)); if (published.Any()) { @@ -2494,15 +2884,19 @@ public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportO { using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); ContentDataIntegrityReport report = _documentRepository.CheckDataIntegrity(options); if (report.FixedIssues.Count > 0) { //The event args needs a content item so we'll make a fake one with enough properties to not cause a null ref - var root = new Content("root", -1, new ContentType(_shortStringHelper, -1)) {Id = -1, Key = Guid.Empty}; - scope.Notifications.Publish(new ContentTreeChangeNotification(root, TreeChangeTypes.RefreshAll, EventMessagesFactory.Get())); + var root = new Content("root", -1, new ContentType(_shortStringHelper, -1)) + { + Id = -1, Key = Guid.Empty + }; + scope.Notifications.Publish(new ContentTreeChangeNotification(root, TreeChangeTypes.RefreshAll, + EventMessagesFactory.Get())); } return report; @@ -2514,15 +2908,15 @@ public ContentDataIntegrityReport CheckDataIntegrity(ContentDataIntegrityReportO #region Internal Methods /// - /// Gets a collection of descendants by the first Parent. + /// Gets a collection of descendants by the first Parent. /// - /// item to retrieve Descendants from - /// An Enumerable list of objects + /// item to retrieve Descendants from + /// An Enumerable list of objects internal IEnumerable GetPublishedDescendants(IContent content) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); + scope.ReadLock(Constants.Locks.ContentTree); return GetPublishedDescendantsLocked(content).ToArray(); // ToArray important in uow! } } @@ -2530,15 +2924,16 @@ internal IEnumerable GetPublishedDescendants(IContent content) internal IEnumerable GetPublishedDescendantsLocked(IContent content) { var pathMatch = content.Path + ","; - var query = Query().Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& x.Trashed == false*/); - var contents = _documentRepository.Get(query); + IQuery query = Query() + .Where(x => x.Id != content.Id && x.Path.StartsWith(pathMatch) /*&& x.Trashed == false*/); + IEnumerable contents = _documentRepository.Get(query); // beware! contents contains all published version below content // including those that are not directly published because below an unpublished content // these must be filtered out here var parents = new List { content.Id }; - foreach (var c in contents) + foreach (IContent c in contents) { if (parents.Contains(c.ParentId)) { @@ -2552,20 +2947,22 @@ internal IEnumerable GetPublishedDescendantsLocked(IContent content) #region Private Methods - private void Audit(AuditType type, int userId, int objectId, string message = null, string parameters = null) - { - _auditRepository.Save(new AuditItem(objectId, type, userId, ObjectTypes.GetName(UmbracoObjectTypes.Document), message, parameters)); - } + private void Audit(AuditType type, int userId, int objectId, string message = null, string parameters = null) => + _auditRepository.Save(new AuditItem(objectId, type, userId, UmbracoObjectTypes.Document.GetName(), message, + parameters)); - private bool IsDefaultCulture(IReadOnlyCollection langs, string culture) => langs.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)); - private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) => langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture)); + private bool IsDefaultCulture(IReadOnlyCollection langs, string culture) => + langs.Any(x => x.IsDefault && x.IsoCode.InvariantEquals(culture)); + + private bool IsMandatoryCulture(IReadOnlyCollection langs, string culture) => + langs.Any(x => x.IsMandatory && x.IsoCode.InvariantEquals(culture)); #endregion #region Publishing Strategies /// - /// Ensures that a document can be published + /// Ensures that a document can be published /// /// /// @@ -2576,40 +2973,54 @@ private void Audit(AuditType type, int userId, int objectId, string message = nu /// /// /// - private PublishResult StrategyCanPublish(IScope scope, IContent content, bool checkPath, IReadOnlyList culturesPublishing, - IReadOnlyCollection culturesUnpublishing, EventMessages evtMsgs, IReadOnlyCollection allLangs, IDictionary notificationState) + private PublishResult StrategyCanPublish(IScope scope, IContent content, bool checkPath, + IReadOnlyList culturesPublishing, + IReadOnlyCollection culturesUnpublishing, EventMessages evtMsgs, + IReadOnlyCollection allLangs, IDictionary notificationState) { // raise Publishing notification - if (scope.Notifications.PublishCancelable(new ContentPublishingNotification(content, evtMsgs).WithState(notificationState))) + if (scope.Notifications.PublishCancelable( + new ContentPublishingNotification(content, evtMsgs).WithState(notificationState))) { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "publishing was cancelled"); + _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, content.Id, "publishing was cancelled"); return new PublishResult(PublishResultType.FailedPublishCancelledByEvent, evtMsgs, content); } var variesByCulture = content.ContentType.VariesByCulture(); - var impactsToPublish = culturesPublishing == null + CultureImpact[] impactsToPublish = culturesPublishing == null ? new[] { CultureImpact.Invariant } //if it's null it's invariant - : culturesPublishing.Select(x => CultureImpact.Explicit(x, allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))).ToArray(); + : culturesPublishing.Select(x => + CultureImpact.Explicit(x, + allLangs.Any(lang => lang.IsoCode.InvariantEquals(x) && lang.IsMandatory))).ToArray(); // publish the culture(s) if (!impactsToPublish.All(content.PublishCulture)) + { return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content); + } //validate the property values IProperty[] invalidProperties = null; - if (!impactsToPublish.All(x => _propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x))) + if (!impactsToPublish.All(x => + _propertyValidationService.Value.IsPropertyDataValid(content, out invalidProperties, x))) + { return new PublishResult(PublishResultType.FailedPublishContentInvalid, evtMsgs, content) { InvalidProperties = invalidProperties }; + } //Check if mandatory languages fails, if this fails it will mean anything that the published flag on the document will // be changed to Unpublished and any culture currently published will not be visible. if (variesByCulture) { if (culturesPublishing == null) - throw new InvalidOperationException("Internal error, variesByCulture but culturesPublishing is null."); + { + throw new InvalidOperationException( + "Internal error, variesByCulture but culturesPublishing is null."); + } if (content.Published && culturesPublishing.Count == 0 && culturesUnpublishing.Count == 0) { @@ -2621,25 +3032,31 @@ private PublishResult StrategyCanPublish(IScope scope, IContent content, bool ch // missing mandatory culture = cannot be published - var mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); - var mandatoryMissing = mandatoryCultures.Any(x => !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); + IEnumerable mandatoryCultures = allLangs.Where(x => x.IsMandatory).Select(x => x.IsoCode); + var mandatoryMissing = mandatoryCultures.Any(x => + !content.PublishedCultures.Contains(x, StringComparer.OrdinalIgnoreCase)); if (mandatoryMissing) + { return new PublishResult(PublishResultType.FailedPublishMandatoryCultureMissing, evtMsgs, content); + } if (culturesPublishing.Count == 0 && culturesUnpublishing.Count > 0) + { return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); + } } // ensure that the document has published values // either because it is 'publishing' or because it already has a published version if (content.PublishedState != PublishedState.Publishing && content.PublishedVersionId == 0) { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document does not have published values"); + _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, content.Id, "document does not have published values"); return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); } //loop over each culture publishing - or string.Empty for invariant - foreach (var culture in culturesPublishing ?? (new[] { string.Empty })) + foreach (var culture in culturesPublishing ?? new[] { string.Empty }) { // ensure that the document status is correct // note: culture will be string.Empty for invariant @@ -2647,20 +3064,45 @@ private PublishResult StrategyCanPublish(IScope scope, IContent content, bool ch { case ContentStatus.Expired: if (!variesByCulture) - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document has expired"); + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, + content.Id, "document has expired"); + } else - _logger.LogInformation("Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document culture has expired"); - return new PublishResult(!variesByCulture ? PublishResultType.FailedPublishHasExpired : PublishResultType.FailedPublishCultureHasExpired, evtMsgs, content); + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", + content.Name, content.Id, culture, "document culture has expired"); + } + + return new PublishResult( + !variesByCulture + ? PublishResultType.FailedPublishHasExpired + : PublishResultType.FailedPublishCultureHasExpired, evtMsgs, content); case ContentStatus.AwaitingRelease: if (!variesByCulture) - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document is awaiting release"); + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, + content.Id, "document is awaiting release"); + } else - _logger.LogInformation("Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", content.Name, content.Id, culture, "document is culture awaiting release"); - return new PublishResult(!variesByCulture ? PublishResultType.FailedPublishAwaitingRelease : PublishResultType.FailedPublishCultureAwaitingRelease, evtMsgs, content); + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) culture {Culture} cannot be published: {Reason}", + content.Name, content.Id, culture, "document is culture awaiting release"); + } + + return new PublishResult( + !variesByCulture + ? PublishResultType.FailedPublishAwaitingRelease + : PublishResultType.FailedPublishCultureAwaitingRelease, evtMsgs, content); case ContentStatus.Trashed: - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "document is trashed"); + _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, content.Id, "document is trashed"); return new PublishResult(PublishResultType.FailedPublishIsTrashed, evtMsgs, content); } } @@ -2670,23 +3112,26 @@ private PublishResult StrategyCanPublish(IScope scope, IContent content, bool ch // check if the content can be path-published // root content can be published // else check ancestors - we know we are not trashed - var pathIsOk = content.ParentId == Cms.Core.Constants.System.Root || IsPathPublished(GetParent(content)); + var pathIsOk = content.ParentId == Constants.System.Root || IsPathPublished(GetParent(content)); if (!pathIsOk) { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", content.Name, content.Id, "parent is not published"); + _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be published: {Reason}", + content.Name, content.Id, "parent is not published"); return new PublishResult(PublishResultType.FailedPublishPathNotPublished, evtMsgs, content); } } //If we are both publishing and unpublishing cultures, then return a mixed status if (variesByCulture && culturesPublishing.Count > 0 && culturesUnpublishing.Count > 0) + { return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); + } return new PublishResult(evtMsgs, content); } /// - /// Publishes a document + /// Publishes a document /// /// /// @@ -2694,7 +3139,8 @@ private PublishResult StrategyCanPublish(IScope scope, IContent content, bool ch /// /// /// - /// It is assumed that all publishing checks have passed before calling this method like + /// It is assumed that all publishing checks have passed before calling this method like + /// /// private PublishResult StrategyPublish(IContent content, IReadOnlyCollection culturesPublishing, IReadOnlyCollection culturesUnpublishing, @@ -2707,31 +3153,44 @@ private PublishResult StrategyPublish(IContent content, if (content.ContentType.VariesByCulture()) { if (content.Published && culturesUnpublishing.Count == 0 && culturesPublishing.Count == 0) + { return new PublishResult(PublishResultType.FailedPublishNothingToPublish, evtMsgs, content); + } if (culturesUnpublishing.Count > 0) - _logger.LogInformation("Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.", + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been unpublished.", content.Name, content.Id, string.Join(",", culturesUnpublishing)); + } if (culturesPublishing.Count > 0) - _logger.LogInformation("Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.", + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) cultures: {Cultures} have been published.", content.Name, content.Id, string.Join(",", culturesPublishing)); + } if (culturesUnpublishing.Count > 0 && culturesPublishing.Count > 0) + { return new PublishResult(PublishResultType.SuccessMixedCulture, evtMsgs, content); + } if (culturesUnpublishing.Count > 0 && culturesPublishing.Count == 0) + { return new PublishResult(PublishResultType.SuccessUnpublishCulture, evtMsgs, content); + } return new PublishResult(PublishResultType.SuccessPublishCulture, evtMsgs, content); } - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, content.Id); + _logger.LogInformation("Document {ContentName} (id={ContentId}) has been published.", content.Name, + content.Id); return new PublishResult(evtMsgs, content); } /// - /// Ensures that a document can be unpublished + /// Ensures that a document can be unpublished /// /// /// @@ -2742,7 +3201,9 @@ private PublishResult StrategyCanUnpublish(IScope scope, IContent content, Event // raise Unpublishing notification if (scope.Notifications.PublishCancelable(new ContentUnpublishingNotification(content, evtMsgs))) { - _logger.LogInformation("Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", content.Name, content.Id); + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) cannot be unpublished: unpublishing was cancelled.", + content.Name, content.Id); return new PublishResult(PublishResultType.FailedUnpublishCancelledByEvent, evtMsgs, content); } @@ -2750,13 +3211,14 @@ private PublishResult StrategyCanUnpublish(IScope scope, IContent content, Event } /// - /// Unpublishes a document + /// Unpublishes a document /// /// /// /// /// - /// It is assumed that all unpublishing checks have passed before calling this method like + /// It is assumed that all unpublishing checks have passed before calling this method like + /// /// private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs) { @@ -2764,22 +3226,33 @@ private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs) //TODO: What is this check?? we just created this attempt and of course it is Success?! if (attempt.Success == false) + { return attempt; + } // if the document has any release dates set to before now, // they should be removed so they don't interrupt an unpublish // otherwise it would remain released == published - var pastReleases = content.ContentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.Now); - foreach (var p in pastReleases) + IReadOnlyList pastReleases = + content.ContentSchedule.GetPending(ContentScheduleAction.Expire, DateTime.Now); + foreach (ContentSchedule p in pastReleases) + { content.ContentSchedule.Remove(p); + } + if (pastReleases.Count > 0) - _logger.LogInformation("Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", content.Name, content.Id); + { + _logger.LogInformation( + "Document {ContentName} (id={ContentId}) had its release date removed, because it was unpublished.", + content.Name, content.Id); + } // change state to unpublishing content.PublishedState = PublishedState.Unpublishing; - _logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, content.Id); + _logger.LogInformation("Document {ContentName} (id={ContentId}) has been unpublished.", content.Name, + content.Id); return attempt; } @@ -2788,16 +3261,18 @@ private PublishResult StrategyUnpublish(IContent content, EventMessages evtMsgs) #region Content Types /// - /// Deletes all content of specified type. All children of deleted content is moved to Recycle Bin. + /// Deletes all content of specified type. All children of deleted content is moved to Recycle Bin. /// /// - /// This needs extra care and attention as its potentially a dangerous and extensive operation. - /// Deletes content items of the specified type, and only that type. Does *not* handle content types - /// inheritance and compositions, which need to be managed outside of this method. + /// This needs extra care and attention as its potentially a dangerous and extensive operation. + /// + /// Deletes content items of the specified type, and only that type. Does *not* handle content types + /// inheritance and compositions, which need to be managed outside of this method. + /// /// - /// Id of the + /// Id of the /// Optional Id of the user issuing the delete operation - public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Cms.Core.Constants.Security.SuperUserId) + public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Constants.Security.SuperUserId) { // TODO: This currently this is called from the ContentTypeService but that needs to change, // if we are deleting a content type, we should just delete the data and do this operation slightly differently. @@ -2817,7 +3292,7 @@ public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Cms.Core // using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); IQuery query = Query().WhereIn(x => x.ContentTypeId, contentTypeIdsA); IContent[] contents = _documentRepository.Get(query).ToArray(); @@ -2847,7 +3322,7 @@ public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Cms.Core foreach (IContent child in children) { // see MoveToRecycleBin - PerformMoveLocked(child, Cms.Core.Constants.System.RecycleBinContent, null, userId, moves, true); + PerformMoveLocked(child, Constants.System.RecycleBinContent, null, userId, moves, true); changes.Add(new TreeChange(content, TreeChangeTypes.RefreshBranch)); } @@ -2864,47 +3339,66 @@ public void DeleteOfTypes(IEnumerable contentTypeIds, int userId = Cms.Core { scope.Notifications.Publish(new ContentMovedToRecycleBinNotification(moveInfos, eventMessages)); } + scope.Notifications.Publish(new ContentTreeChangeNotification(changes, eventMessages)); - Audit(AuditType.Delete, userId, Cms.Core.Constants.System.Root, $"Delete content of type {string.Join(",", contentTypeIdsA)}"); + Audit(AuditType.Delete, userId, Constants.System.Root, + $"Delete content of type {string.Join(",", contentTypeIdsA)}"); scope.Complete(); } } /// - /// Deletes all content items of specified type. All children of deleted content item is moved to Recycle Bin. + /// Deletes all content items of specified type. All children of deleted content item is moved to Recycle Bin. /// /// This needs extra care and attention as its potentially a dangerous and extensive operation - /// Id of the + /// Id of the /// Optional id of the user deleting the media - public void DeleteOfType(int contentTypeId, int userId = Cms.Core.Constants.Security.SuperUserId) - { + public void DeleteOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) => DeleteOfTypes(new[] { contentTypeId }, userId); - } private IContentType GetContentType(IScope scope, string contentTypeAlias) { - if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } + + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", + nameof(contentTypeAlias)); + } - scope.ReadLock(Cms.Core.Constants.Locks.ContentTypes); + scope.ReadLock(Constants.Locks.ContentTypes); - var query = Query().Where(x => x.Alias == contentTypeAlias); - var contentType = _contentTypeRepository.Get(query).FirstOrDefault(); + IQuery query = Query().Where(x => x.Alias == contentTypeAlias); + IContentType contentType = _contentTypeRepository.Get(query).FirstOrDefault(); if (contentType == null) - throw new Exception($"No ContentType matching the passed in Alias: '{contentTypeAlias}' was found"); // causes rollback + { + throw new Exception( + $"No ContentType matching the passed in Alias: '{contentTypeAlias}' was found"); // causes rollback + } return contentType; } private IContentType GetContentType(string contentTypeAlias) { - if (contentTypeAlias == null) throw new ArgumentNullException(nameof(contentTypeAlias)); - if (string.IsNullOrWhiteSpace(contentTypeAlias)) throw new ArgumentException("Value can't be empty or consist only of white-space characters.", nameof(contentTypeAlias)); + if (contentTypeAlias == null) + { + throw new ArgumentNullException(nameof(contentTypeAlias)); + } - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + if (string.IsNullOrWhiteSpace(contentTypeAlias)) + { + throw new ArgumentException("Value can't be empty or consist only of white-space characters.", + nameof(contentTypeAlias)); + } + + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { return GetContentType(scope, contentTypeAlias); } @@ -2916,51 +3410,61 @@ private IContentType GetContentType(string contentTypeAlias) public IContent GetBlueprintById(int id) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var blueprint = _documentBlueprintRepository.Get(id); + scope.ReadLock(Constants.Locks.ContentTree); + IContent blueprint = _documentBlueprintRepository.Get(id); if (blueprint != null) + { blueprint.Blueprint = true; + } + return blueprint; } } public IContent GetBlueprintById(Guid id) { - using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) { - scope.ReadLock(Cms.Core.Constants.Locks.ContentTree); - var blueprint = _documentBlueprintRepository.Get(id); + scope.ReadLock(Constants.Locks.ContentTree); + IContent blueprint = _documentBlueprintRepository.Get(id); if (blueprint != null) + { blueprint.Blueprint = true; + } + return blueprint; } } - public void SaveBlueprint(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); //always ensure the blueprint is at the root if (content.ParentId != -1) + { content.ParentId = -1; + } content.Blueprint = true; - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); if (content.HasIdentity == false) { content.CreatorId = userId; } + content.WriterId = userId; _documentBlueprintRepository.Save(content); - Audit(AuditType.Save, Cms.Core.Constants.Security.SuperUserId, content.Id, $"Saved content template: {content.Name}"); + Audit(AuditType.Save, Constants.Security.SuperUserId, content.Id, + $"Saved content template: {content.Name}"); scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, evtMsgs)); @@ -2968,13 +3472,13 @@ public void SaveBlueprint(IContent content, int userId = Cms.Core.Constants.Secu } } - public void DeleteBlueprint(IContent content, int userId = Cms.Core.Constants.Security.SuperUserId) + public void DeleteBlueprint(IContent content, int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); _documentBlueprintRepository.Delete(content); scope.Notifications.Publish(new ContentDeletedBlueprintNotification(content, evtMsgs)); scope.Complete(); @@ -2983,11 +3487,15 @@ public void DeleteBlueprint(IContent content, int userId = Cms.Core.Constants.Se private static readonly string[] ArrayOfOneNullString = { null }; - public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Cms.Core.Constants.Security.SuperUserId) + public IContent CreateContentFromBlueprint(IContent blueprint, string name, + int userId = Constants.Security.SuperUserId) { - if (blueprint == null) throw new ArgumentNullException(nameof(blueprint)); + if (blueprint == null) + { + throw new ArgumentNullException(nameof(blueprint)); + } - var contentType = GetContentType(blueprint.ContentType.Alias); + IContentType contentType = GetContentType(blueprint.ContentType.Alias); var content = new Content(name, -1, contentType); content.Path = string.Concat(content.ParentId.ToString(), ",", content.Id); @@ -2998,9 +3506,10 @@ public IContent CreateContentFromBlueprint(IContent blueprint, string name, int if (blueprint.CultureInfos.Count > 0) { cultures = blueprint.CultureInfos.Values.Select(x => x.Culture); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out var defaultCulture)) + if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), + out ContentCultureInfos defaultCulture)) { defaultCulture.Name = name; } @@ -3009,10 +3518,10 @@ public IContent CreateContentFromBlueprint(IContent blueprint, string name, int } } - var now = DateTime.Now; + DateTime now = DateTime.Now; foreach (var culture in cultures) { - foreach (var property in blueprint.Properties) + foreach (IProperty property in blueprint.Properties) { var propertyCulture = property.PropertyType.VariesByCulture() ? culture : null; content.SetValue(property.Alias, property.GetValue(propertyCulture), propertyCulture); @@ -3031,11 +3540,12 @@ public IEnumerable GetBlueprintsForContentTypes(params int[] contentTy { using (ScopeProvider.CreateScope(autoComplete: true)) { - var query = Query(); + IQuery query = Query(); if (contentTypeId.Length > 0) { query.Where(x => contentTypeId.Contains(x.ContentTypeId)); } + return _documentBlueprintRepository.Get(query).Select(x => { x.Blueprint = true; @@ -3044,26 +3554,29 @@ public IEnumerable GetBlueprintsForContentTypes(params int[] contentTy } } - public void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId = Cms.Core.Constants.Security.SuperUserId) + public void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, + int userId = Constants.Security.SuperUserId) { - var evtMsgs = EventMessagesFactory.Get(); + EventMessages evtMsgs = EventMessagesFactory.Get(); - using (var scope = ScopeProvider.CreateScope()) + using (IScope scope = ScopeProvider.CreateScope()) { - scope.WriteLock(Cms.Core.Constants.Locks.ContentTree); + scope.WriteLock(Constants.Locks.ContentTree); var contentTypeIdsA = contentTypeIds.ToArray(); - var query = Query(); + IQuery query = Query(); if (contentTypeIdsA.Length > 0) + { query.Where(x => contentTypeIdsA.Contains(x.ContentTypeId)); + } - var blueprints = _documentBlueprintRepository.Get(query).Select(x => + IContent[] blueprints = _documentBlueprintRepository.Get(query).Select(x => { x.Blueprint = true; return x; }).ToArray(); - foreach (var blueprint in blueprints) + foreach (IContent blueprint in blueprints) { _documentBlueprintRepository.Delete(blueprint); } @@ -3073,69 +3586,8 @@ public void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId } } - public void DeleteBlueprintsOfType(int contentTypeId, int userId = Cms.Core.Constants.Security.SuperUserId) - { + public void DeleteBlueprintsOfType(int contentTypeId, int userId = Constants.Security.SuperUserId) => DeleteBlueprintsOfTypes(new[] { contentTypeId }, userId); - } - - #endregion - - #region Rollback - - public OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Cms.Core.Constants.Security.SuperUserId) - { - var evtMsgs = EventMessagesFactory.Get(); - - //Get the current copy of the node - var content = GetById(id); - - //Get the version - var version = GetVersion(versionId); - - //Good ole null checks - if (content == null || version == null || content.Trashed) - { - return new OperationResult(OperationResultType.FailedCannot, evtMsgs); - } - - //Store the result of doing the save of content for the rollback - OperationResult rollbackSaveResult; - - using (var scope = ScopeProvider.CreateScope()) - { - var rollingBackNotification = new ContentRollingBackNotification(content, evtMsgs); - if (scope.Notifications.PublishCancelable(rollingBackNotification)) - { - scope.Complete(); - return OperationResult.Cancel(evtMsgs); - } - - //Copy the changes from the version - content.CopyFrom(version, culture); - - //Save the content for the rollback - rollbackSaveResult = Save(content, userId); - - //Depending on the save result - is what we log & audit along with what we return - if (rollbackSaveResult.Success == false) - { - //Log the error/warning - _logger.LogError("User '{UserId}' was unable to rollback content '{ContentId}' to version '{VersionId}'", userId, id, versionId); - } - else - { - scope.Notifications.Publish(new ContentRolledBackNotification(content, evtMsgs).WithStateFrom(rollingBackNotification)); - - //Logging & Audit message - _logger.LogInformation("User '{UserId}' rolled back content '{ContentId}' to version '{VersionId}'", userId, id, versionId); - Audit(AuditType.RollBack, userId, id, $"Content '{content.Name}' was rolled back to version '{versionId}'"); - } - - scope.Complete(); - } - - return rollbackSaveResult; - } #endregion } diff --git a/src/Umbraco.Infrastructure/Services/Implement/ContentVersionService.cs b/src/Umbraco.Infrastructure/Services/Implement/ContentVersionService.cs new file mode 100644 index 000000000000..c8ce9ce707a7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/ContentVersionService.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services.Implement +{ + internal class ContentVersionService : IContentVersionService + { + private readonly ILogger _logger; + private readonly IDocumentVersionRepository _documentVersionRepository; + private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy; + private readonly IScopeProvider _scopeProvider; + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly IAuditRepository _auditRepository; + + public ContentVersionService( + ILogger logger, + IDocumentVersionRepository documentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + IScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditRepository auditRepository) + { + _logger = logger; + _documentVersionRepository = documentVersionRepository; + _contentVersionCleanupPolicy = contentVersionCleanupPolicy; + _scopeProvider = scopeProvider; + _eventMessagesFactory = eventMessagesFactory; + _auditRepository = auditRepository; + } + + /// + /// + /// In v9 this can live in another class as we publish the notifications via IEventAggregator. + /// But for v8 must be here for access to the static events. + /// + public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) => + CleanupDocumentVersions(asAtDate); + + // Media - ignored + // Members - ignored + /// + /// v9 - move to another class + /// + private IReadOnlyCollection CleanupDocumentVersions(DateTime asAtDate) + { + List versionsToDelete; + + /* Why so many scopes? + * + * We could just work out the set to delete at SQL infra level which was the original plan, however we agreed that really we should fire + * ContentService.DeletingVersions so people can hook & cancel if required. + * + * On first time run of cleanup on a site with a lot of history there may be a lot of historic ContentVersions to remove e.g. 200K for our.umbraco.com. + * If we weren't supporting SQL CE we could do TVP, or use temp tables to bulk delete with joins to our list of version ids to nuke. + * (much nicer, we can kill 100k in sub second time-frames). + * + * However we are supporting SQL CE, so the easiest thing to do is use the Umbraco InGroupsOf helper to create a query with 2K args of version + * ids to delete at a time. + * + * This is already done at the repository level, however if we only had a single scope at service level we're still locking + * the ContentVersions table (and other related tables) for a couple of minutes which makes the back office unusable. + * + * As a quick fix, we can also use InGroupsOf at service level, create a scope per group to give other connections a chance + * to grab the locks and execute their queries. + * + * This makes the back office a tiny bit sluggish during first run but it is usable for loading tree and publishing content. + * + * There are optimizations we can do, we could add a bulk delete for SqlServerSyntaxProvider which differs in implementation + * and fallback to this naive approach only for SQL CE, however we agreed it is not worth the effort as this is a one time pain, + * subsequent runs shouldn't have huge numbers of versions to cleanup. + * + * tl;dr lots of scopes to enable other connections to use the DB whilst we work. + */ + using (IScope scope = _scopeProvider.CreateScope(autoComplete: true)) + { + var allHistoricVersions = _documentVersionRepository.GetDocumentVersionsEligibleForCleanup(); + + _logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count); + versionsToDelete = new List(allHistoricVersions.Count); + + var filteredContentVersions = _contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions); + + foreach (var version in filteredContentVersions) + { + EventMessages evtMsgs = _eventMessagesFactory.Get(); + + if (scope.Notifications.PublishCancelable(new ContentDeletingVersionsNotification(version.ContentId, evtMsgs, version.VersionId))) + { + _logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); + continue; + } + + versionsToDelete.Add(version); + } + } + + if (!versionsToDelete.Any()) + { + _logger.LogDebug("No remaining ContentVersions for cleanup", versionsToDelete.Count); + return Array.Empty(); + } + + _logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count); + + foreach (var group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + using (IScope scope = _scopeProvider.CreateScope(autoComplete: true)) + { + scope.WriteLock(Constants.Locks.ContentTree); + var groupEnumerated = group.ToList(); + _documentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); + + foreach (var version in groupEnumerated) + { + EventMessages evtMsgs = _eventMessagesFactory.Get(); + + scope.Notifications.Publish( + new ContentDeletedVersionsNotification(version.ContentId, evtMsgs, version.VersionId)); + } + } + } + + using (IScope scope = _scopeProvider.CreateScope(autoComplete: true)) + { + Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy"); + } + + return versionsToDelete; + } + + private void Audit(AuditType type, int userId, int objectId, string message = null, string parameters = null) => + _auditRepository.Save(new AuditItem(objectId, type, userId, UmbracoObjectTypes.Document.GetName(), message, + parameters)); + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/DefaultContentVersionCleanupPolicy.cs b/src/Umbraco.Infrastructure/Services/Implement/DefaultContentVersionCleanupPolicy.cs new file mode 100644 index 000000000000..755d30306ba2 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/DefaultContentVersionCleanupPolicy.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Core.Models; +using Umbraco.Core.Persistence.Repositories; +using Umbraco.Core.Services; +using ContentVersionCleanupPolicySettings = Umbraco.Core.Models.ContentVersionCleanupPolicySettings; + +namespace Umbraco.Cms.Infrastructure.Services.Implement +{ + public class DefaultContentVersionCleanupPolicy : IContentVersionCleanupPolicy + { + private readonly IOptions _contentSettings; + private readonly IScopeProvider _scopeProvider; + private readonly IDocumentVersionRepository _documentVersionRepository; + + public DefaultContentVersionCleanupPolicy(IOptions contentSettings, IScopeProvider scopeProvider, IDocumentVersionRepository documentVersionRepository) + { + _contentSettings = contentSettings ?? throw new ArgumentNullException(nameof(contentSettings)); + _scopeProvider = scopeProvider ?? throw new ArgumentNullException(nameof(scopeProvider)); + _documentVersionRepository = documentVersionRepository ?? throw new ArgumentNullException(nameof(documentVersionRepository)); + } + + public IEnumerable Apply(DateTime asAtDate, IEnumerable items) + { + // Note: Not checking global enable flag, that's handled in the scheduled job. + // If this method is called and policy is globally disabled someone has chosen to run in code. + + var globalPolicy = _contentSettings.Value.ContentVersionCleanupPolicy; + + var theRest = new List(); + + using(_scopeProvider.CreateScope(autoComplete: true)) + { + var policyOverrides = _documentVersionRepository.GetCleanupPolicies() + .ToDictionary(x => x.ContentTypeId); + + foreach (var version in items) + { + var age = asAtDate - version.VersionDate; + + var overrides = GetOverridePolicy(version, policyOverrides); + + var keepAll = overrides?.KeepAllVersionsNewerThanDays ?? globalPolicy.KeepAllVersionsNewerThanDays!; + var keepLatest = overrides?.KeepLatestVersionPerDayForDays ?? globalPolicy.KeepLatestVersionPerDayForDays; + var preventCleanup = overrides?.PreventCleanup ?? false; + + if (preventCleanup) + { + continue; + } + + if (age.TotalDays <= keepAll) + { + continue; + } + + if (age.TotalDays > keepLatest) + { + + yield return version; + continue; + } + + theRest.Add(version); + } + + var grouped = theRest.GroupBy(x => new + { + x.ContentId, + x.VersionDate.Date + }); + + foreach (var group in grouped) + { + yield return group.OrderByDescending(x => x.VersionId).First(); + } + } + } + + private ContentVersionCleanupPolicySettings GetOverridePolicy( + HistoricContentVersionMeta version, + IDictionary overrides) + { + _ = overrides.TryGetValue(version.ContentTypeId, out var value); + + return value; + } + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 2d584f198ea5..e3e3462e6a0d 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -176,6 +176,7 @@ public static IUmbracoBuilder AddHostedServices(this IUmbracoBuilder builder) builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js index 40266f7ac5c5..81888d574dee 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/umbdataformatter.service.js @@ -63,7 +63,7 @@ var saveModel = _.pick(displayModel, 'compositeContentTypes', 'isContainer', 'allowAsRoot', 'allowedTemplates', 'allowedContentTypes', 'alias', 'description', 'thumbnail', 'name', 'id', 'icon', 'trashed', - 'key', 'parentId', 'alias', 'path', 'allowCultureVariant', 'allowSegmentVariant', 'isElement'); + 'key', 'parentId', 'alias', 'path', 'allowCultureVariant', 'allowSegmentVariant', 'isElement','historyCleanup'); saveModel.allowedTemplates = _.map(displayModel.allowedTemplates, function (t) { return t.alias; }); saveModel.defaultTemplate = displayModel.defaultTemplate ? displayModel.defaultTemplate.alias : null; diff --git a/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.controller.js b/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.controller.js index da293a6820d9..dc2520020124 100644 --- a/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.controller.js @@ -27,6 +27,7 @@ vm.toggleAllowSegmentVariants = toggleAllowSegmentVariants; vm.canToggleIsElement = false; vm.toggleIsElement = toggleIsElement; + vm.toggleHistoryCleanupPreventCleanup = toggleHistoryCleanupPreventCleanup; /* ---------- INIT ---------- */ @@ -55,6 +56,11 @@ } else { vm.canToggleIsElement = true; } + + if(!$scope.model.historyCleanup){ + $scope.model.historyCleanup = {}; + } + } function addChild($event) { @@ -62,7 +68,7 @@ var editor = { multiPicker: true, filterCssClass: 'not-allowed not-published', - filter: item => + filter: item => !vm.contentTypes.some(x => x.udi == item.udi) || vm.selectedChildren.some(x => x.udi === item.udi), submit: model => { model.selection.forEach(item => @@ -73,7 +79,7 @@ editorService.close(); }, - close: () => editorService.close() + close: () => editorService.close() }; editorService.contentTypePicker(editor); @@ -111,6 +117,10 @@ $scope.model.isElement = $scope.model.isElement ? false : true; } + function toggleHistoryCleanupPreventCleanup() { + $scope.model.historyCleanup.preventCleanup = $scope.model.historyCleanup.preventCleanup ? false : true; + } + } angular.module('umbraco').controller('Umbraco.Editors.DocumentType.PermissionsController', PermissionsController); diff --git a/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.html b/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.html index 96cf9b8ec471..84bef68274ef 100644 --- a/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.html +++ b/src/Umbraco.Web.UI.Client/src/views/documentTypes/views/permissions/permissions.html @@ -92,6 +92,32 @@
Is an Element Type + +
+ +
+
+ +
+ +
+ + + + + + + + + + + + + +
+ +
+ diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml index 72ad387baed4..8e1065bc3304 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en.xml @@ -1824,6 +1824,11 @@ To manage your website, simply open the Umbraco backoffice and start adding cont types beneath a node. using this editor will get updated with the new settings. + History cleanup + Allow override the global settings for when history versions are removed. + Keep all versions newer than days + Keep latest version per day for days + Prevent cleanup Add language diff --git a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml index eb8538af2078..1dd15b373f4a 100644 --- a/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml +++ b/src/Umbraco.Web.UI/umbraco/config/lang/en_us.xml @@ -1881,6 +1881,11 @@ To manage your website, simply open the Umbraco backoffice and start adding cont types beneath a node. using this editor will get updated with the new settings. + History cleanup + Allow override the global settings for when history versions are removed. + Keep all versions newer than days + Keep latest version per day for days + Prevent cleanup Add language diff --git a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs index af51f6019ec2..d6202f27a497 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentTypeBuilder.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Builders.Interfaces; using Constants = Umbraco.Cms.Core.Constants; @@ -113,6 +114,7 @@ public override IContentType Build() contentType.CreatorId = GetCreatorId(); contentType.Trashed = GetTrashed(); contentType.IsContainer = GetIsContainer(); + contentType.HistoryCleanup = new HistoryCleanup(); contentType.Variations = contentVariation; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs new file mode 100644 index 000000000000..6ac6fd406069 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Persistence.Repositories +{ + /// + /// v9 -> Tests.Integration + /// + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] + public class DocumentVersionRepositoryTest : UmbracoIntegrationTest + { + public IFileService FileService => GetRequiredService(); + public IContentTypeService ContentTypeService => GetRequiredService(); + public IContentService ContentService => GetRequiredService(); + + [Test] + public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesActiveVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage"); + FileService.SaveTemplate(contentType.DefaultTemplate); + ContentTypeService.Save(contentType); + + var content = ContentBuilder.CreateSimpleContent(contentType); + + ContentService.SaveAndPublish(content); + // At this point content has 2 versions, a draft version and a published version. + + ContentService.SaveAndPublish(content); + // At this point content has 3 versions, a historic version, a draft version and a published version. + + using (ScopeProvider.CreateScope()) + { + var sut = new DocumentVersionRepository(ScopeAccessor); + var results = sut.GetDocumentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results.First().VersionId); + }); + } + } + + [Test] + public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesPinnedVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage"); + FileService.SaveTemplate(contentType.DefaultTemplate); + ContentTypeService.Save(contentType); + + var content = ContentBuilder.CreateSimpleContent(contentType); + + ContentService.SaveAndPublish(content); + // At this point content has 2 versions, a draft version and a published version. + ContentService.SaveAndPublish(content); + ContentService.SaveAndPublish(content); + ContentService.SaveAndPublish(content); + // At this point content has 5 versions, 3 historic versions, a draft version and a published version. + + var allVersions = ContentService.GetVersions(content.Id); + Debug.Assert(allVersions.Count() == 5); // Sanity check + + using (var scope = ScopeProvider.CreateScope()) + { + scope.Database.Update("set preventCleanup = 1 where id in (1,3)"); + + var sut = new DocumentVersionRepository(ScopeAccessor); + var results = sut.GetDocumentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + + // We pinned 1 & 3 + // 4 is current + // 5 is published + // So all that is left is 2 + Assert.AreEqual(2, results.First().VersionId); + }); + } + } + + [Test] + public void DeleteVersions_Always_DeletesSpecifiedVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage"); + FileService.SaveTemplate(contentType.DefaultTemplate); + ContentTypeService.Save(contentType); + + var content = ContentBuilder.CreateSimpleContent(contentType); + + ContentService.SaveAndPublish(content); + ContentService.SaveAndPublish(content); + ContentService.SaveAndPublish(content); + ContentService.SaveAndPublish(content); + using (var scope = ScopeProvider.CreateScope()) + { + var query = scope.SqlContext.Sql(); + + query.Select() + .From(); + + var sut = new DocumentVersionRepository(ScopeAccessor); + sut.DeleteVersions(new []{1,2,3}); + + var after = scope.Database.Fetch(query); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.Count); + Assert.True(after.All(x => x.Id > 3)); + }); + } + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionCleanupServiceTest.cs new file mode 100644 index 000000000000..1a03482fb6c0 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentVersionCleanupServiceTest.cs @@ -0,0 +1,112 @@ +using System; +using System.Diagnostics; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services +{ + [TestFixture] + [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] + internal class ContentVersionCleanupServiceTest : UmbracoIntegrationTest + { + public IFileService FileService => GetRequiredService(); + + public IContentTypeService ContentTypeService => GetRequiredService(); + + public IContentService ContentService => GetRequiredService(); + + /// + /// This is covered by the unit tests, but nice to know it deletes on infra. + /// And proves implementation is compatible with SQL CE. + /// + [Test] + public void PerformContentVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive() + { + // For reference currently has + // 5000 Documents + // With 200K Versions + // With 11M Property data + + ContentType contentTypeA = ContentTypeBuilder.CreateSimpleContentType("contentTypeA", "contentTypeA"); + FileService.SaveTemplate(contentTypeA.DefaultTemplate); + ContentTypeService.Save(contentTypeA); + + Content content = ContentBuilder.CreateSimpleContent(contentTypeA); + ContentService.SaveAndPublish(content); + + for (var i = 0; i < 10; i++) + { + ContentService.SaveAndPublish(content); + } + + Report before = GetReport(); + + Debug.Assert(before.ContentVersions == 12); // 10 historic + current draft + current published + Debug.Assert(before.PropertyData == 12 * 3); // CreateSimpleContentType = 3 props + + // Kill all historic + InsertCleanupPolicy(contentTypeA, 0, 0); + + ((IContentVersionService)ContentService).PerformContentVersionCleanup(DateTime.Now.AddHours(1)); + + Report after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.ContentVersions); // current draft, current published + Assert.AreEqual(2, after.DocumentVersions); + Assert.AreEqual(6, after.PropertyData); // CreateSimpleContentType = 3 props + }); + } + + private Report GetReport() + { + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) + { + // SQL CE is fun! + var contentVersions = scope.Database.Single(@"select count(1) from umbracoContentVersion"); + var documentVersions = scope.Database.Single(@"select count(1) from umbracoDocumentVersion"); + var propertyData = scope.Database.Single(@"select count(1) from umbracoPropertyData"); + + return new Report + { + ContentVersions = contentVersions, + DocumentVersions = documentVersions, + PropertyData = propertyData + }; + } + } + + private void InsertCleanupPolicy(IContentType contentType, int daysToKeepAll, int daysToRollupAll, bool preventCleanup = false) + { + using (IScope scope = ScopeProvider.CreateScope(autoComplete: true)) + { + var entity = new ContentVersionCleanupPolicyDto + { + ContentTypeId = contentType.Id, + KeepAllVersionsNewerThanDays = daysToKeepAll, + KeepLatestVersionPerDayForDays = daysToRollupAll, + PreventCleanup = preventCleanup, + Updated = DateTime.Today + }; + + scope.Database.Insert(entity); + } + } + + private class Report + { + public int ContentVersions { get; set; } + + public int DocumentVersions { get; set; } + + public int PropertyData { get; set; } + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs new file mode 100644 index 000000000000..040c5059b072 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scheduling/ContentVersionCleanupTest.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; +using AutoFixture.NUnit3; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Tests.UnitTests.AutoFixture; + +namespace Umbraco.Tests.Scheduling +{ + [TestFixture] + class ContentVersionCleanupTest + { + [Test, AutoMoqData] + public async Task ContentVersionCleanup_WhenNotEnabled_DoesNotCleanupWillRepeat( + [Frozen] Mock> settings, + [Frozen] Mock mainDom, + [Frozen] Mock serverRoleAccessor, + [Frozen] Mock runtimeState, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(false); + + runtimeState.Setup(x => x.Level).Returns(RuntimeLevel.Run); + mainDom.Setup(x => x.IsMainDom).Returns(true); + serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); + + await sut.PerformExecuteAsync(null); + + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + } + + [Test, AutoMoqData] + public async Task ContentVersionCleanup_RuntimeLevelNotRun_DoesNotCleanupWillRepeat( + [Frozen] Mock> settings, + [Frozen] Mock mainDom, + [Frozen] Mock serverRoleAccessor, + [Frozen] Mock runtimeState, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + runtimeState.Setup(x => x.Level).Returns(RuntimeLevel.Unknown); + mainDom.Setup(x => x.IsMainDom).Returns(true); + serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); + + await sut.PerformExecuteAsync(null); + + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + } + + [Test, AutoMoqData] + public async Task ContentVersionCleanup_ServerRoleUnknown_DoesNotCleanupWillRepeat( + [Frozen] Mock> settings, + [Frozen] Mock mainDom, + [Frozen] Mock serverRoleAccessor, + [Frozen] Mock runtimeState, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + runtimeState.Setup(x => x.Level).Returns(RuntimeLevel.Run); + mainDom.Setup(x => x.IsMainDom).Returns(true); + serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.Unknown); + + await sut.PerformExecuteAsync(null); + + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + } + + [Test, AutoMoqData] + public async Task ContentVersionCleanup_NotMainDom_DoesNotCleanupWillNotRepeat( + [Frozen] Mock> settings, + [Frozen] Mock mainDom, + [Frozen] Mock serverRoleAccessor, + [Frozen] Mock runtimeState, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + runtimeState.Setup(x => x.Level).Returns(RuntimeLevel.Run); + mainDom.Setup(x => x.IsMainDom).Returns(false); + serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); + + await sut.PerformExecuteAsync(null); + + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Never); + } + + [Test, AutoMoqData] + public async Task ContentVersionCleanup_Enabled_DelegatesToCleanupService( + [Frozen] Mock> settings, + [Frozen] Mock mainDom, + [Frozen] Mock serverRoleAccessor, + [Frozen] Mock runtimeState, + [Frozen] Mock cleanupService, + ContentVersionCleanup sut) + { + settings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + runtimeState.Setup(x => x.Level).Returns(RuntimeLevel.Run); + mainDom.Setup(x => x.IsMainDom).Returns(true); + serverRoleAccessor.Setup(x => x.CurrentServerRole).Returns(ServerRole.SchedulingPublisher); + + + await sut.PerformExecuteAsync(null); + + cleanupService.Verify(x => x.PerformContentVersionCleanup(It.IsAny()), Times.Once); + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs new file mode 100644 index 000000000000..00f5e53b47a5 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs @@ -0,0 +1,266 @@ +// using System; +// using System.Collections.Generic; +// using System.Diagnostics; +// using System.Linq; +// using AutoFixture.NUnit3; +// using CSharpTest.Net.Interfaces; +// using Moq; +// using NUnit.Framework; +// using Umbraco.Cms.Core.Events; +// using Umbraco.Cms.Core.Models; +// using Umbraco.Cms.Core.Scoping; +// using Umbraco.Cms.Core.Services; +// using Umbraco.Cms.Core.Services.Implement; +// using Umbraco.Cms.Tests.UnitTests.AutoFixture; +// using Umbraco.Core.Composing; +// using Umbraco.Core.Events; +// using Umbraco.Core.Models; +// using Umbraco.Core.Persistence.Repositories; +// using Umbraco.Core.Scoping; +// using Umbraco.Core.Services; +// using Umbraco.Core.Services.Implement; +// using Umbraco.Tests.Testing; +// +// namespace Umbraco.Tests.Services +// { +// /// +// /// v9 -> Tests.UnitTests +// /// Sut here is ContentService, but in v9 should be a new class +// /// +// [TestFixture] +// public class ContentVersionCleanupServiceTest +// { +// +// +// /// +// /// For v9 this just needs a rewrite, no static events, no service location etc +// /// +// [Test, AutoMoqData] +// public void PerformContentVersionCleanup_Always_RespectsDeleteRevisionsCancellation( +// [Frozen] Mock scope, +// Mock documentVersionRepository, +// List someHistoricVersions, +// DateTime aDateTime, +// ContentService sut) +// { +// factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) +// .Returns(documentVersionRepository.Object); +// +// factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) +// .Returns(new EchoingCleanupPolicyStub()); +// +// documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) +// .Returns(someHistoricVersions); +// +// scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); +// +// // Wire up service locator +// Current.Factory = factory.Object; +// +// void OnDeletingVersions(IContentService sender, DeleteRevisionsEventArgs args) => args.Cancel = true; +// +// ContentService.DeletingVersions += OnDeletingVersions; +// +// // # Act +// var report = sut.PerformContentVersionCleanup(aDateTime); +// +// ContentService.DeletingVersions -= OnDeletingVersions; +// +// Assert.AreEqual(0, report.Count); +// } +// +// /// +// /// For v9 this just needs a rewrite, no static events, no service location etc +// /// +// [Test, AutoMoqData] +// public void PerformContentVersionCleanup_Always_FiresDeletedVersionsForEachDeletedVersion( +// [Frozen] Mock factory, +// [Frozen] Mock scope, +// Mock documentVersionRepository, +// List someHistoricVersions, +// DateTime aDateTime, +// ContentService sut) +// { +// factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) +// .Returns(documentVersionRepository.Object); +// +// factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) +// .Returns(new EchoingCleanupPolicyStub()); +// +// documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) +// .Returns(someHistoricVersions); +// +// scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); +// +// // Wire up service locator +// Current.Factory = factory.Object; +// +// // v9 can Mock + Verify +// var deletedAccordingToEvents = 0; +// void OnDeletedVersions(IContentService sender, DeleteRevisionsEventArgs args) => deletedAccordingToEvents++; +// +// ContentService.DeletedVersions += OnDeletedVersions; +// +// // # Act +// sut.PerformContentVersionCleanup(aDateTime); +// +// ContentService.DeletedVersions -= OnDeletedVersions; +// +// Assert.Multiple(() => +// { +// Assert.Greater(deletedAccordingToEvents, 0); +// Assert.AreEqual(someHistoricVersions.Count, deletedAccordingToEvents); +// }); +// } +// +// /// +// /// For v9 this just needs a rewrite, no static events, no service location etc +// /// +// [Test, AutoMoqData] +// public void PerformContentVersionCleanup_Always_ReturnsReportOfDeletedItems( +// [Frozen] Mock factory, +// [Frozen] Mock scope, +// Mock documentVersionRepository, +// List someHistoricVersions, +// DateTime aDateTime, +// ContentService sut) +// { +// factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) +// .Returns(documentVersionRepository.Object); +// +// factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) +// .Returns(new EchoingCleanupPolicyStub()); +// +// documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) +// .Returns(someHistoricVersions); +// +// scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); +// +// // Wire up service locator +// Current.Factory = factory.Object; +// +// // # Act +// var report = sut.PerformContentVersionCleanup(aDateTime); +// +// Assert.Multiple(() => +// { +// Assert.Greater(report.Count, 0); +// Assert.AreEqual(someHistoricVersions.Count, report.Count); +// }); +// } +// +// /// +// /// For v9 this just needs a rewrite, no static events, no service location etc +// /// +// [Test, AutoMoqData] +// public void PerformContentVersionCleanup_Always_AdheresToCleanupPolicy( +// [Frozen] Mock factory, +// [Frozen] Mock scope, +// Mock documentVersionRepository, +// Mock cleanupPolicy, +// List someHistoricVersions, +// DateTime aDateTime, +// ContentService sut) +// { +// factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) +// .Returns(documentVersionRepository.Object); +// +// factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) +// .Returns(cleanupPolicy.Object); +// +// documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) +// .Returns(someHistoricVersions); +// +// scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); +// +// cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) +// .Returns>((_, items) => items.Take(1)); +// +// // Wire up service locator +// Current.Factory = factory.Object; +// +// // # Act +// var report = sut.PerformContentVersionCleanup(aDateTime); +// +// Debug.Assert(someHistoricVersions.Count > 1); +// +// Assert.Multiple(() => +// { +// cleanupPolicy.Verify(x => x.Apply(aDateTime, someHistoricVersions), Times.Once); +// Assert.AreEqual(someHistoricVersions.First(), report.Single()); +// }); +// } +// +// /// +// /// For v9 this just needs a rewrite, no static events, no service location etc +// /// +// [Test, AutoMoqData] +// public void PerformContentVersionCleanup_HasVersionsToDelete_CallsDeleteOnRepositoryWithFilteredSet( +// [Frozen] Mock factory, +// [Frozen] Mock scope, +// Mock documentVersionRepository, +// Mock cleanupPolicy, +// List someHistoricVersions, +// DateTime aDateTime, +// ContentService sut) +// { +// factory.Setup(x => x.GetInstance(typeof(IDocumentVersionRepository))) +// .Returns(documentVersionRepository.Object); +// +// factory.Setup(x => x.GetInstance(typeof(IContentVersionCleanupPolicy))) +// .Returns(cleanupPolicy.Object); +// +// documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) +// .Returns(someHistoricVersions); +// +// scope.Setup(x => x.Events).Returns(new PassThroughEventDispatcher()); +// +// var filteredSet = someHistoricVersions.Take(1); +// +// cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) +// .Returns>((_, items) => filteredSet); +// +// // Wire up service locator +// Current.Factory = factory.Object; +// +// // # Act +// var report = sut.PerformContentVersionCleanup(aDateTime); +// +// Debug.Assert(someHistoricVersions.Any()); +// +// var expectedId = filteredSet.First().VersionId; +// +// documentVersionRepository.Verify(x => x.DeleteVersions(It.Is>(y => y.Single() == expectedId)), Times.Once); +// } +// +// class EchoingCleanupPolicyStub : IContentVersionCleanupPolicy +// { +// /// +// /// What goes in, must come out +// /// +// public EchoingCleanupPolicyStub() { } +// +// /* Note: Could just wire up a mock but its quite wordy. +// * +// * cleanupPolicy.Setup(x => x.Apply(It.IsAny(), It.IsAny>())) +// * .Returns>((date, items) => items); +// */ +// public IEnumerable Apply( +// DateTime asAtDate, +// IEnumerable items +// ) => items; +// } +// +// /// +// /// NPoco < 5 requires a parameter-less constructor but plays nice with get-only properties. +// /// Moq won't play nice with get-only properties, but doesn't require a parameter-less constructor. +// /// +// /// Inheritance solves this so that we get values for test data without a specimen builder +// /// +// public class TestHistoricContentVersionMeta : HistoricContentVersionMeta +// { +// public TestHistoricContentVersionMeta(int contentId, int contentTypeId, int versionId, DateTime versionDate) +// : base(contentId, contentTypeId, versionId, versionDate) { } +// } +// } +// } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs new file mode 100644 index 000000000000..a51c77c03a3b --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutoFixture.NUnit3; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Services.Implement; +using Umbraco.Cms.Tests.UnitTests.AutoFixture; +using Umbraco.Core.Persistence.Repositories; +using ContentVersionCleanupPolicySettings = Umbraco.Core.Models.ContentVersionCleanupPolicySettings; + +namespace Umbraco.Tests.Services +{ + [TestFixture] + public class DefaultContentVersionCleanupPolicyTest + { + [Test, AutoMoqData] + public void Apply_AllOlderThanKeepSettings_AllVersionsReturned( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var versionId = 0; + + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(0); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(0); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.AreEqual(2, results.Count); + } + + [Test, AutoMoqData] + public void Apply_OverlappingKeepSettings_KeepAllVersionsNewerThanDaysTakesPriority( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var versionId = 0; + + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + new HistoricContentVersionMeta(versionId: ++versionId, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(2); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(2); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.AreEqual(0, results.Count); + } + + [Test, AutoMoqData] + public void Apply_WithinInKeepLatestPerDay_ReturnsSinglePerContentPerDay( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + + new HistoricContentVersionMeta(versionId: 4, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddDays(-1).AddHours(-1)), + // another content + new HistoricContentVersionMeta(versionId: 7, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 8, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 9, contentId: 2, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(0); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(3); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(Array.Empty()); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.Multiple(() => + { + Assert.AreEqual(3, results.Count); + Assert.True(results.Exists(x => x.VersionId == 3)); + Assert.True(results.Exists(x => x.VersionId == 6)); + Assert.True(results.Exists(x => x.VersionId == 9)); + }); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsPreventCleanup( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(0); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(0); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = true } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.True(results.All(x => x.ContentTypeId == 1)); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsKeepAll( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(0); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(0); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = false, KeepAllVersionsNewerThanDays = 3 } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.True(results.All(x => x.ContentTypeId == 1)); + } + + [Test, AutoMoqData] + public void Apply_HasOverridePolicy_RespectsKeepLatest( + [Frozen] Mock documentVersionRepository, + [Frozen] Mock> contentSettings, + DefaultContentVersionCleanupPolicy sut) + { + var historicItems = new List + { + new HistoricContentVersionMeta(versionId: 1, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 2, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 3, contentId: 1, contentTypeId: 1, versionDate: DateTime.Today.AddHours(-1)), + // another content & type + new HistoricContentVersionMeta(versionId: 4, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-3)), + new HistoricContentVersionMeta(versionId: 5, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-2)), + new HistoricContentVersionMeta(versionId: 6, contentId: 2, contentTypeId: 2, versionDate: DateTime.Today.AddHours(-1)), + }; + + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.EnableCleanup).Returns(true); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepAllVersionsNewerThanDays).Returns(0); + contentSettings.Setup(x => x.Value.ContentVersionCleanupPolicy.KeepLatestVersionPerDayForDays).Returns(0); + + documentVersionRepository.Setup(x => x.GetCleanupPolicies()) + .Returns(new ContentVersionCleanupPolicySettings[] + { + new ContentVersionCleanupPolicySettings{ ContentTypeId = 2, PreventCleanup = false, KeepLatestVersionPerDayForDays = 3 } + }); + + documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + .Returns(historicItems); + + var results = sut.Apply(DateTime.Today, historicItems).ToList(); + + Assert.Multiple(() => + { + Assert.AreEqual(3, results.Count(x => x.ContentTypeId == 1)); + Assert.AreEqual(6, results.Single(x => x.ContentTypeId == 2).VersionId); + }); + } + + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index eee8083425b2..668741bb87a2 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index 2da13a4bedfa..b70b6ae65847 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -502,7 +502,9 @@ private MemberController CreateSut( new Mock().Object, mockShortStringHelper, globalSettings, - new Mock().Object) + new Mock().Object, + new Mock>().Object + ) }); var scopeProvider = Mock.Of(x => x.CreateScope( It.IsAny(),