diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs index a09554756e77..c4f96990ba95 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/AllDocumentVersionController.cs @@ -41,14 +41,17 @@ public async Task All( Attempt?, ContentVersionOperationStatus> attempt = await _contentVersionService.GetPagedContentVersionsAsync(documentId, culture, skip, take); + if (attempt.Success is false) + { + return MapFailure(attempt.Status); + } + var pagedViewModel = new PagedViewModel { Total = attempt.Result!.Total, Items = await _documentVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items), }; - return attempt.Success - ? Ok(pagedViewModel) - : MapFailure(attempt.Status); + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs index 1e31f6a0788c..e201eb284c9a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentVersion/DocumentVersionControllerBase.cs @@ -13,25 +13,26 @@ namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion; [Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)] public abstract class DocumentVersionControllerBase : ManagementApiControllerBase { - protected IActionResult MapFailure(ContentVersionOperationStatus status) + internal static IActionResult MapFailure(ContentVersionOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { - ContentVersionOperationStatus.NotFound => NotFound(problemDetailsBuilder + ContentVersionOperationStatus.NotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The requested version could not be found") .Build()), - ContentVersionOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + ContentVersionOperationStatus.ContentNotFound => new NotFoundObjectResult(problemDetailsBuilder .WithTitle("The requested document could not be found") .Build()), ContentVersionOperationStatus.InvalidSkipTake => SkipTakeToPagingProblem(), - ContentVersionOperationStatus.RollBackFailed => BadRequest(problemDetailsBuilder + ContentVersionOperationStatus.RollBackFailed => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("Rollback failed") - .WithDetail("An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")), - ContentVersionOperationStatus.RollBackCanceled => BadRequest(problemDetailsBuilder + .WithDetail( + "An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")), + ContentVersionOperationStatus.RollBackCanceled => new BadRequestObjectResult(problemDetailsBuilder .WithTitle("Request cancelled by notification") .WithDetail("The request to roll back was cancelled by a notification handler.") .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + _ => new ObjectResult(problemDetailsBuilder .WithTitle("Unknown content version operation status.") - .Build()), + .Build()) { StatusCode = StatusCodes.Status500InternalServerError }, }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs new file mode 100644 index 000000000000..53aa5a970fbf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/AllElementVersionController.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class AllElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IElementVersionPresentationFactory _elementVersionPresentationFactory; + + public AllElementVersionController( + IElementVersionService elementVersionService, + IElementVersionPresentationFactory elementVersionPresentationFactory) + { + _elementVersionService = elementVersionService; + _elementVersionPresentationFactory = elementVersionPresentationFactory; + } + + [MapToApiVersion("1.0")] + [HttpGet] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task All( + CancellationToken cancellationToken, + [Required] Guid elementId, + string? culture, + int skip = 0, + int take = 100) + { + Attempt?, ContentVersionOperationStatus> attempt = + await _elementVersionService.GetPagedContentVersionsAsync(elementId, culture, skip, take); + + if (attempt.Success is false) + { + return MapFailure(attempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = attempt.Result!.Total, + Items = await _elementVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items), + }; + + return Ok(pagedViewModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs new file mode 100644 index 000000000000..5c80b5f3c2f6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ByKeyElementVersionController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class ByKeyElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IUmbracoMapper _umbracoMapper; + + public ByKeyElementVersionController( + IElementVersionService elementVersionService, + IUmbracoMapper umbracoMapper) + { + _elementVersionService = elementVersionService; + _umbracoMapper = umbracoMapper; + } + + [MapToApiVersion("1.0")] + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ElementVersionResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task ByKey(CancellationToken cancellationToken, Guid id) + { + Attempt attempt = + await _elementVersionService.GetAsync(id); + + return attempt.Success + ? Ok(_umbracoMapper.Map(attempt.Result)) + : MapFailure(attempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs new file mode 100644 index 000000000000..33822509c497 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/ElementVersionControllerBase.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.DocumentVersion; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Element}-version")] +[ApiExplorerSettings(GroupName = $"{nameof(Constants.UdiEntityType.Element)} Version")] +public abstract class ElementVersionControllerBase : ManagementApiControllerBase +{ + protected IActionResult MapFailure(ContentVersionOperationStatus status) + => DocumentVersionControllerBase.MapFailure(status); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs new file mode 100644 index 000000000000..af8fff5ebc54 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/RollbackElementVersionController.cs @@ -0,0 +1,59 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class RollbackElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public RollbackElementVersionController( + IElementVersionService elementVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementVersionService = elementVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [MapToApiVersion("1.0")] + [HttpPost("{id:guid}/rollback")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Rollback(CancellationToken cancellationToken, Guid id, string? culture) + { + Attempt getContentAttempt = await _elementVersionService.GetAsync(id); + if (getContentAttempt.Success is false || getContentAttempt.Result is null) + { + return MapFailure(getContentAttempt.Status); + } + + + // TODO ELEMENTS: handle auth + // IElement element = getContentAttempt.Result; + // AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + // User, + // ContentPermissionResource.WithKeys(ActionRollback.ActionLetter, element.Key), + // AuthorizationPolicies.ContentPermissionByResource); + // + // if (!authorizationResult.Succeeded) + // { + // return Forbidden(); + // } + + Attempt rollBackAttempt = + await _elementVersionService.RollBackAsync(id, culture, CurrentUserKey(_backOfficeSecurityAccessor)); + + return rollBackAttempt.Success + ? Ok() + : MapFailure(rollBackAttempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs new file mode 100644 index 000000000000..f19bdbec99ce --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/ElementVersion/UpdatePreventCleanupElementVersionController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.ElementVersion; + +[ApiVersion("1.0")] +public class UpdatePreventCleanupElementVersionController : ElementVersionControllerBase +{ + private readonly IElementVersionService _elementVersionService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public UpdatePreventCleanupElementVersionController( + IElementVersionService elementVersionService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + { + _elementVersionService = elementVersionService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [MapToApiVersion("1.0")] + [HttpPut("{id:guid}/prevent-cleanup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Set(CancellationToken cancellationToken, Guid id, bool preventCleanup) + { + Attempt attempt = + await _elementVersionService.SetPreventCleanupAsync(id, preventCleanup, CurrentUserKey(_backOfficeSecurityAccessor)); + + return attempt.Success + ? Ok() + : MapFailure(attempt.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index 24fdbae5449b..ee76bce0f334 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -66,8 +66,8 @@ protected static IActionResult OperationStatusResult(TEnum status, Func

result(new ProblemDetailsBuilder().WithOperationStatus(status)); - protected BadRequestObjectResult SkipTakeToPagingProblem() => - BadRequest(new ProblemDetails + protected static BadRequestObjectResult SkipTakeToPagingProblem() => + new(new ProblemDetails { Title = "Invalid skip/take", Detail = "Skip must be a multiple of take - i.e. skip = 10, take = 5", diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs index d880a60a3014..7b6afd2cb0b0 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/ElementBuilderExtensions.cs @@ -12,9 +12,11 @@ internal static IUmbracoBuilder AddElements(this IUmbracoBuilder builder) { builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() - .Add(); + .Add() + .Add(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs b/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs new file mode 100644 index 000000000000..d732dc3520ab --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ContentVersionPresentationFactoryBase.cs @@ -0,0 +1,65 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal abstract class ContentVersionPresentationFactoryBase +{ + private readonly IEntityService _entityService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + + protected abstract UmbracoObjectTypes ItemObjectType { get; } + + protected ContentVersionPresentationFactoryBase(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + { + _entityService = entityService; + _userIdKeyResolver = userIdKeyResolver; + } + + protected abstract TVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup); + + public async Task CreateAsync(ContentVersionMeta contentVersion) + { + ReferenceByIdModel userReference = contentVersion.UserId switch + { + Constants.Security.UnknownUserId => new ReferenceByIdModel(), + _ => new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)), + }; + + return VersionItemResponseModelFactory( + contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB + new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, ItemObjectType).Result), + new ReferenceByIdModel( + _entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType) + .Result), + userReference, + new DateTimeOffset(contentVersion.VersionDate), + contentVersion.CurrentPublishedVersion, + contentVersion.CurrentDraftVersion, + contentVersion.PreventCleanup); + } + + public async Task> CreateMultipleAsync(IEnumerable contentVersions) + => await CreateMultipleImplAsync(contentVersions).ToArrayAsync(); + + private async IAsyncEnumerable CreateMultipleImplAsync(IEnumerable contentVersions) + { + foreach (ContentVersionMeta contentVersion in contentVersions) + { + yield return await CreateAsync(contentVersion); + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs index 144be70a4c29..7a981c2a5152 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentVersionPresentationFactory.cs @@ -1,54 +1,27 @@ using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; -internal sealed class DocumentVersionPresentationFactory : IDocumentVersionPresentationFactory +internal sealed class DocumentVersionPresentationFactory : ContentVersionPresentationFactoryBase, IDocumentVersionPresentationFactory { - private readonly IEntityService _entityService; - private readonly IUserIdKeyResolver _userIdKeyResolver; - - public DocumentVersionPresentationFactory( - IEntityService entityService, - IUserIdKeyResolver userIdKeyResolver) + public DocumentVersionPresentationFactory(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + : base(entityService, userIdKeyResolver) { - _entityService = entityService; - _userIdKeyResolver = userIdKeyResolver; } - public async Task CreateAsync(ContentVersionMeta contentVersion) - { - ReferenceByIdModel userReference = contentVersion.UserId switch - { - Constants.Security.UnknownUserId => new ReferenceByIdModel(), - _ => new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)), - }; - - return new DocumentVersionItemResponseModel( - contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB - new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, UmbracoObjectTypes.Document).Result), - new ReferenceByIdModel( - _entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType) - .Result), - userReference, - new DateTimeOffset(contentVersion.VersionDate), - contentVersion.CurrentPublishedVersion, - contentVersion.CurrentDraftVersion, - contentVersion.PreventCleanup); - } + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document; - public async Task> CreateMultipleAsync(IEnumerable contentVersions) - => await CreateMultipleImplAsync(contentVersions).ToArrayAsync(); - - private async IAsyncEnumerable CreateMultipleImplAsync(IEnumerable contentVersions) - { - foreach (ContentVersionMeta contentVersion in contentVersions) - { - yield return await CreateAsync(contentVersion); - } - } + protected override DocumentVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + => new(versionId, item, documentType, user, versionDate, isCurrentPublishedVersion, isCurrentDraftVersion, preventCleanup); } diff --git a/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs new file mode 100644 index 000000000000..c94b3d2f1c9b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/ElementVersionPresentationFactory.cs @@ -0,0 +1,30 @@ +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Factories; + +internal sealed class ElementVersionPresentationFactory : ContentVersionPresentationFactoryBase, IElementVersionPresentationFactory +{ + public ElementVersionPresentationFactory(IEntityService entityService, IUserIdKeyResolver userIdKeyResolver) + : base(entityService, userIdKeyResolver) + { + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override ElementVersionItemResponseModel VersionItemResponseModelFactory( + Guid versionId, + ReferenceByIdModel item, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + => new(versionId, item, documentType, user, versionDate, isCurrentPublishedVersion, isCurrentDraftVersion, preventCleanup); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs new file mode 100644 index 000000000000..102483c2f322 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IElementVersionPresentationFactory.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Api.Management.Factories; + +public interface IElementVersionPresentationFactory +{ + Task CreateAsync(ContentVersionMeta contentVersion); + + Task> CreateMultipleAsync( + IEnumerable contentVersions); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs index 0012d244a774..f3ef85240b57 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentVersionMapDefinition.cs @@ -34,6 +34,7 @@ public void DefineMaps(IUmbracoMapper mapper) mapper.Define((_, _) => new DocumentVersionResponseModel(), Map); } + // Umbraco.Code.MapAll -Flags private void Map(IContent source, DocumentVersionResponseModel target, MapperContext context) { target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs new file mode 100644 index 000000000000..65d8747e4fc2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/Element/ElementVersionMapDefinition.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Api.Management.Mapping.Content; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; +using Umbraco.Cms.Api.Management.ViewModels.Element; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Mapping.Element; + +public class ElementVersionMapDefinition : ContentMapDefinition, IMapDefinition +{ + public ElementVersionMapDefinition(PropertyEditorCollection propertyEditorCollection, IDataValueEditorFactory dataValueEditorFactory) + : base(propertyEditorCollection, dataValueEditorFactory) + { + } + + public void DefineMaps(IUmbracoMapper mapper) + => mapper.Define((_, _) => new ElementVersionResponseModel(), Map); + + // Umbraco.Code.MapAll -Flags + private void Map(IElement source, ElementVersionResponseModel target, MapperContext context) + { + target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB + target.Element = new ReferenceByIdModel(source.Key); + target.DocumentType = context.Map(source.ContentType)!; + target.Values = MapValueViewModels(source.Properties); + target.Variants = MapVariantViewModels( + source, + (culture, _, documentVariantViewModel) => + { + documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture); + documentVariantViewModel.PublishDate = culture == null + ? source.PublishDate + : source.GetPublishDate(culture); + }); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs new file mode 100644 index 000000000000..db615a7f7fc6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionItemResponseModel.cs @@ -0,0 +1,41 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVersionItemResponseModel +{ + public ElementVersionItemResponseModel( + Guid id, + ReferenceByIdModel element, + ReferenceByIdModel documentType, + ReferenceByIdModel user, + DateTimeOffset versionDate, + bool isCurrentPublishedVersion, + bool isCurrentDraftVersion, + bool preventCleanup) + { + Id = id; + Element = element; + DocumentType = documentType; + + User = user; + VersionDate = versionDate; + IsCurrentPublishedVersion = isCurrentPublishedVersion; + IsCurrentDraftVersion = isCurrentDraftVersion; + PreventCleanup = preventCleanup; + } + + public Guid Id { get; } + + public ReferenceByIdModel Element { get; } + + public ReferenceByIdModel DocumentType { get; } + + public ReferenceByIdModel User { get; } + + public DateTimeOffset VersionDate { get; } + + public bool IsCurrentPublishedVersion { get; } + + public bool IsCurrentDraftVersion { get; } + + public bool PreventCleanup { get; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs new file mode 100644 index 000000000000..f3fdc39229bb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Element/ElementVersionResponseModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Element; + +public class ElementVersionResponseModel : ElementResponseModelBase +{ + public ReferenceByIdModel? Element { get; set; } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index ef2b14b3960c..57b57c0d8110 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -300,6 +300,7 @@ private void AddCoreServices() Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs index b8d326566643..f1f15d1d7ad6 100644 --- a/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs +++ b/src/Umbraco.Core/Models/ContentRepositoryExtensions.cs @@ -147,7 +147,7 @@ public static void AdjustDates(this IPublishableContentBase content, DateTime da ///

/// Copies values from another document. /// - public static void CopyFrom(this IContent content, IPublishableContentBase other, string? culture = "*") + public static void CopyFrom(this IPublishableContentBase content, IPublishableContentBase other, string? culture = "*") { if (other.ContentTypeId != content.ContentTypeId) { diff --git a/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs b/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs new file mode 100644 index 000000000000..c381d80de1b9 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementRolledBackNotification.cs @@ -0,0 +1,17 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; +/// +/// A notification that is used to trigger the IElementService when the Rollback method is called in the API, after the content has been rolled back. +/// +public sealed class ElementRolledBackNotification : RolledBackNotification +{ + public ElementRolledBackNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs b/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs new file mode 100644 index 000000000000..9a32b0d9d9f1 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementRollingBackNotification.cs @@ -0,0 +1,17 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; +/// +/// A notification that is used to trigger the IElementService when the Rollback method is called in the API. +/// +public sealed class ElementRollingBackNotification : RollingBackNotification +{ + public ElementRollingBackNotification(IElement target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs new file mode 100644 index 000000000000..fc7158f097b0 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IContentVersionRepository.cs @@ -0,0 +1,36 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IContentVersionRepository : IRepository +{ + /// + /// Gets a list of all historic content versions. + /// + IReadOnlyCollection GetContentVersionsEligibleForCleanup(); + + /// + /// Gets cleanup policy override settings per content type. + /// + IReadOnlyCollection GetCleanupPolicies(); + + /// + /// Gets paginated content versions for given content id paginated. + /// + IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null); + + /// + /// Deletes multiple content versions by ID. + /// + void DeleteVersions(IEnumerable versionIds); + + /// + /// Updates the prevent cleanup flag on a content version. + /// + void SetPreventCleanup(int versionId, bool preventCleanup); + + /// + /// Gets the content version metadata for a specific version. + /// + ContentVersionMeta? Get(int versionId); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs index 9902c8df302c..492831443604 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IDocumentVersionRepository.cs @@ -1,36 +1,5 @@ -using Umbraco.Cms.Core.Models; - namespace Umbraco.Cms.Core.Persistence.Repositories; -public interface IDocumentVersionRepository : IRepository +public interface IDocumentVersionRepository : IContentVersionRepository { - /// - /// Gets a list of all historic content versions. - /// - public IReadOnlyCollection GetDocumentVersionsEligibleForCleanup(); - - /// - /// Gets cleanup policy override settings per content type. - /// - public IReadOnlyCollection GetCleanupPolicies(); - - /// - /// Gets paginated content versions for given content id paginated. - /// - public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null); - - /// - /// Deletes multiple content versions by ID. - /// - void DeleteVersions(IEnumerable versionIds); - - /// - /// Updates the prevent cleanup flag on a content version. - /// - void SetPreventCleanup(int versionId, bool preventCleanup); - - /// - /// Gets the content version metadata for a specific version. - /// - ContentVersionMeta? Get(int versionId); } diff --git a/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs new file mode 100644 index 000000000000..1aedb2aeb0e7 --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IElementVersionRepository.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IElementVersionRepository : IContentVersionRepository +{ +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index cfa087d4abde..a11788eb8456 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -186,67 +186,6 @@ public ContentService( #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 old 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 (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - 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 - #region Permissions /// @@ -2146,11 +2085,11 @@ protected override CancelableEnumerableObjectNotification Unpublishing protected override IStatefulNotification UnpublishedNotification(IContent content, EventMessages eventMessages) => new ContentUnpublishedNotification(content, eventMessages); - protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) - => new ContentDeletingVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + protected override RollingBackNotification RollingBackNotification(IContent target, EventMessages messages) + => new ContentRollingBackNotification(target, messages); - protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) - => new ContentDeletedVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + protected override RolledBackNotification RolledBackNotification(IContent target, EventMessages messages) + => new ContentRolledBackNotification(target, messages); #endregion } diff --git a/src/Umbraco.Core/Services/ContentVersionService.cs b/src/Umbraco.Core/Services/ContentVersionService.cs index 230aa2212623..f042744b1039 100644 --- a/src/Umbraco.Core/Services/ContentVersionService.cs +++ b/src/Umbraco.Core/Services/ContentVersionService.cs @@ -1,34 +1,18 @@ using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Events; -using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; -using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Core.Services.Pagination; -using Umbraco.Extensions; // ReSharper disable once CheckNamespace namespace Umbraco.Cms.Core.Services; -internal sealed class ContentVersionService : IContentVersionService +internal sealed class ContentVersionService : ContentVersionServiceBase, IContentVersionService { - private readonly IAuditService _auditService; - private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy; - private readonly IDocumentVersionRepository _documentVersionRepository; - private readonly IEventMessagesFactory _eventMessagesFactory; - private readonly ILanguageRepository _languageRepository; - private readonly IEntityService _entityService; - private readonly IContentService _contentService; - private readonly IUserIdKeyResolver _userIdKeyResolver; - private readonly ILogger _logger; - private readonly ICoreScopeProvider _scopeProvider; - public ContentVersionService( ILogger logger, - IDocumentVersionRepository documentVersionRepository, + IDocumentVersionRepository contentVersionRepository, IContentVersionCleanupPolicy contentVersionCleanupPolicy, ICoreScopeProvider scopeProvider, IEventMessagesFactory eventMessagesFactory, @@ -37,282 +21,25 @@ public ContentVersionService( IEntityService entityService, IContentService contentService, IUserIdKeyResolver userIdKeyResolver) + : base( + logger, + contentVersionRepository, + contentVersionCleanupPolicy, + scopeProvider, + eventMessagesFactory, + auditService, + languageRepository, + entityService, + contentService, + userIdKeyResolver) { - _logger = logger; - _documentVersionRepository = documentVersionRepository; - _contentVersionCleanupPolicy = contentVersionCleanupPolicy; - _scopeProvider = scopeProvider; - _eventMessagesFactory = eventMessagesFactory; - _auditService = auditService; - _languageRepository = languageRepository; - _entityService = entityService; - _contentService = contentService; - _userIdKeyResolver = userIdKeyResolver; - } - - /// - public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) => - - // Media - ignored - // Members - ignored - CleanupDocumentVersions(asAtDate); - - public ContentVersionMeta? Get(int versionId) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) - { - scope.ReadLock(Constants.Locks.ContentTree); - return _documentVersionRepository.Get(versionId); - } - } - - public Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take) - { - IEntitySlim? document = _entityService.Get(contentId, UmbracoObjectTypes.Document); - if (document is null) - { - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound)); - } - - if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false) - { - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake)); - } - - IEnumerable versions = - HandleGetPagedContentVersions( - document.Id, - pageNumber, - pageSize, - out var total, - culture); - - return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Succeed( - ContentVersionOperationStatus.Success, new PagedModel(total, versions))); - } - - public Task> GetAsync(Guid versionId) - { - IContent? version = _contentService.GetVersion(versionId.ToInt()); - if (version is null) - { - return Task.FromResult(Attempt.Fail(ContentVersionOperationStatus.NotFound)); - } - - return Task.FromResult(Attempt.Succeed(ContentVersionOperationStatus.Success, version)); } - public async Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey) - { - ContentVersionMeta? version = Get(versionId.ToInt()); - if (version is null) - { - return Attempt.Fail(ContentVersionOperationStatus.NotFound); - } - - HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey)); - - return Attempt.Succeed(ContentVersionOperationStatus.Success); - } - - public async Task> RollBackAsync(Guid versionId, string? culture, Guid userKey) - { - ContentVersionMeta? version = Get(versionId.ToInt()); - if (version is null) - { - return Attempt.Fail(ContentVersionOperationStatus.NotFound); - } - - OperationResult rollBackResult = _contentService.Rollback( - version.ContentId, - version.VersionId, - culture ?? "*", - await _userIdKeyResolver.GetAsync(userKey)); - - if (rollBackResult.Success) - { - return Attempt.Succeed(ContentVersionOperationStatus.Success); - } - - switch (rollBackResult.Result) - { - case OperationResultType.Failed: - case OperationResultType.FailedCannot: - case OperationResultType.FailedExceptionThrown: - case OperationResultType.NoOperation: - default: - return Attempt.Fail(ContentVersionOperationStatus.RollBackFailed); - case OperationResultType.FailedCancelledByEvent: - return Attempt.Fail(ContentVersionOperationStatus.RollBackCanceled); - } - } - - private IEnumerable HandleGetPagedContentVersions( - int contentId, - long pageIndex, - int pageSize, - out long totalRecords, - string? culture = null) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) - { - var languageId = _languageRepository.GetIdByIsoCode(culture, true); - scope.ReadLock(Constants.Locks.ContentTree); - return _documentVersionRepository.GetPagedItemsByContentId(contentId, pageIndex, pageSize, out totalRecords, languageId); - } - } - - private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - _documentVersionRepository.SetPreventCleanup(versionId, preventCleanup); + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document; - ContentVersionMeta? version = _documentVersionRepository.Get(versionId); + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ContentDeletingVersionsNotification(id, messages, specificVersion); - if (version is null) - { - scope.Complete(); - return; - } - - AuditType auditType = preventCleanup - ? AuditType.ContentVersionPreventCleanup - : AuditType.ContentVersionEnableCleanup; - - var message = $"set preventCleanup = '{preventCleanup}' for version '{versionId}'"; - - Audit(auditType, userId, version.ContentId, message, $"{version.VersionDate}"); - scope.Complete(); - } - } - - 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 (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - IReadOnlyCollection allHistoricVersions = - _documentVersionRepository.GetDocumentVersionsEligibleForCleanup(); - - if (allHistoricVersions.Count == 0) - { - scope.Complete(); - return Array.Empty(); - } - - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count); - } - - versionsToDelete = new List(allHistoricVersions.Count); - - IEnumerable filteredContentVersions = - _contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions); - - foreach (ContentVersionMeta version in filteredContentVersions) - { - EventMessages messages = _eventMessagesFactory.Get(); - - if (scope.Notifications.PublishCancelable( - new ContentDeletingVersionsNotification(version.ContentId, messages, version.VersionId))) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); - } - continue; - } - - versionsToDelete.Add(version); - } - - scope.Complete(); - } - - if (!versionsToDelete.Any()) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("No remaining ContentVersions for cleanup"); - } - return Array.Empty(); - } - - _logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count); - - foreach (IEnumerable group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) - { - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - scope.WriteLock(Constants.Locks.ContentTree); - var groupEnumerated = group.ToList(); - _documentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); - - foreach (ContentVersionMeta version in groupEnumerated) - { - EventMessages messages = _eventMessagesFactory.Get(); - - scope.Notifications.Publish( - new ContentDeletedVersionsNotification(version.ContentId, messages, version.VersionId)); - } - - scope.Complete(); - } - } - - using (ICoreScope scope = _scopeProvider.CreateCoreScope()) - { - Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy"); - - scope.Complete(); - } - - return versionsToDelete; - } - - private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => - AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); - - private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) - { - Guid userKey = await _userIdKeyResolver.GetAsync(userId); - - await _auditService.AddAsync( - type, - userKey, - objectId, - UmbracoObjectTypes.Document.GetName(), - message, - parameters); - } + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ContentDeletedVersionsNotification(id, messages, specificVersion); } diff --git a/src/Umbraco.Core/Services/ContentVersionServiceBase.cs b/src/Umbraco.Core/Services/ContentVersionServiceBase.cs new file mode 100644 index 000000000000..5654db7d6eb7 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentVersionServiceBase.cs @@ -0,0 +1,322 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Extensions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Services.Pagination; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal abstract class ContentVersionServiceBase + where TContent : class, IPublishableContentBase +{ + private readonly IAuditService _auditService; + private readonly IContentVersionCleanupPolicy _contentVersionCleanupPolicy; + private readonly IContentVersionRepository _contentVersionRepository; + private readonly IEventMessagesFactory _eventMessagesFactory; + private readonly ILanguageRepository _languageRepository; + private readonly IEntityService _entityService; + private readonly IPublishableContentService _contentService; + private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly ILogger> _logger; + private readonly ICoreScopeProvider _scopeProvider; + + protected abstract UmbracoObjectTypes ItemObjectType { get; } + + public ContentVersionServiceBase( + ILogger> logger, + IContentVersionRepository contentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + ICoreScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + ILanguageRepository languageRepository, + IEntityService entityService, + IPublishableContentService contentService, + IUserIdKeyResolver userIdKeyResolver) + { + _logger = logger; + _contentVersionRepository = contentVersionRepository; + _contentVersionCleanupPolicy = contentVersionCleanupPolicy; + _scopeProvider = scopeProvider; + _eventMessagesFactory = eventMessagesFactory; + _auditService = auditService; + _languageRepository = languageRepository; + _entityService = entityService; + _contentService = contentService; + _userIdKeyResolver = userIdKeyResolver; + } + + protected abstract DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion); + + protected abstract DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion); + + /// + public IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate) => + + // Media - ignored + // Members - ignored + CleanupItemVersions(asAtDate); + + public ContentVersionMeta? Get(int versionId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + { + scope.ReadLock(Constants.Locks.ContentTree); + return _contentVersionRepository.Get(versionId); + } + } + + public Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take) + { + IEntitySlim? entity = _entityService.Get(contentId, ItemObjectType); + if (entity is null) + { + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound)); + } + + if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false) + { + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake)); + } + + IEnumerable versions = + HandleGetPagedContentVersions( + entity.Id, + pageNumber, + pageSize, + out var total, + culture); + + return Task.FromResult(Attempt?, ContentVersionOperationStatus>.Succeed( + ContentVersionOperationStatus.Success, new PagedModel(total, versions))); + } + + public Task> GetAsync(Guid versionId) + { + TContent? version = _contentService.GetVersion(versionId.ToInt()); + if (version is null) + { + return Task.FromResult(Attempt.Fail(ContentVersionOperationStatus.NotFound)); + } + + return Task.FromResult(Attempt.Succeed(ContentVersionOperationStatus.Success, version)); + } + + public async Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey)); + + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + public async Task> RollBackAsync(Guid versionId, string? culture, Guid userKey) + { + ContentVersionMeta? version = Get(versionId.ToInt()); + if (version is null) + { + return Attempt.Fail(ContentVersionOperationStatus.NotFound); + } + + OperationResult rollBackResult = _contentService.Rollback( + version.ContentId, + version.VersionId, + culture ?? "*", + await _userIdKeyResolver.GetAsync(userKey)); + + if (rollBackResult.Success) + { + return Attempt.Succeed(ContentVersionOperationStatus.Success); + } + + switch (rollBackResult.Result) + { + case OperationResultType.Failed: + case OperationResultType.FailedCannot: + case OperationResultType.FailedExceptionThrown: + case OperationResultType.NoOperation: + default: + return Attempt.Fail(ContentVersionOperationStatus.RollBackFailed); + case OperationResultType.FailedCancelledByEvent: + return Attempt.Fail(ContentVersionOperationStatus.RollBackCanceled); + } + } + + private IEnumerable HandleGetPagedContentVersions( + int contentId, + long pageIndex, + int pageSize, + out long totalRecords, + string? culture = null) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + { + var languageId = _languageRepository.GetIdByIsoCode(culture, true); + scope.ReadLock(Constants.Locks.ContentTree); + return _contentVersionRepository.GetPagedItemsByContentId(contentId, pageIndex, pageSize, out totalRecords, languageId); + } + } + + private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + _contentVersionRepository.SetPreventCleanup(versionId, preventCleanup); + + ContentVersionMeta? version = _contentVersionRepository.Get(versionId); + + if (version is null) + { + scope.Complete(); + return; + } + + AuditType auditType = preventCleanup + ? AuditType.ContentVersionPreventCleanup + : AuditType.ContentVersionEnableCleanup; + + var message = $"set preventCleanup = '{preventCleanup}' for version '{versionId}'"; + + Audit(auditType, userId, version.ContentId, message, $"{version.VersionDate}"); + scope.Complete(); + } + } + + private IReadOnlyCollection CleanupItemVersions(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 (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + IReadOnlyCollection allHistoricVersions = + _contentVersionRepository.GetContentVersionsEligibleForCleanup(); + + if (allHistoricVersions.Count == 0) + { + scope.Complete(); + return Array.Empty(); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Discovered {count} candidate(s) for ContentVersion cleanup", allHistoricVersions.Count); + } + + versionsToDelete = new List(allHistoricVersions.Count); + + IEnumerable filteredContentVersions = + _contentVersionCleanupPolicy.Apply(asAtDate, allHistoricVersions); + + foreach (ContentVersionMeta version in filteredContentVersions) + { + EventMessages messages = _eventMessagesFactory.Get(); + + if (scope.Notifications.PublishCancelable(DeletingVersionsNotification(version.ContentId, messages, version.VersionId))) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Delete cancelled for ContentVersion [{versionId}]", version.VersionId); + } + continue; + } + + versionsToDelete.Add(version); + } + + scope.Complete(); + } + + if (!versionsToDelete.Any()) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No remaining ContentVersions for cleanup"); + } + return Array.Empty(); + } + + _logger.LogDebug("Removing {count} ContentVersion(s)", versionsToDelete.Count); + + foreach (IEnumerable group in versionsToDelete.InGroupsOf(Constants.Sql.MaxParameterCount)) + { + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + scope.WriteLock(Constants.Locks.ContentTree); + var groupEnumerated = group.ToList(); + _contentVersionRepository.DeleteVersions(groupEnumerated.Select(x => x.VersionId)); + + foreach (ContentVersionMeta version in groupEnumerated) + { + EventMessages messages = _eventMessagesFactory.Get(); + + scope.Notifications.Publish(DeletedVersionsNotification(version.ContentId, messages, version.VersionId)); + } + + scope.Complete(); + } + } + + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + { + Audit(AuditType.Delete, Constants.Security.SuperUserId, -1, $"Removed {versionsToDelete.Count} ContentVersion(s) according to cleanup policy"); + + scope.Complete(); + } + + return versionsToDelete; + } + + private void Audit(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) => + AuditAsync(type, userId, objectId, message, parameters).GetAwaiter().GetResult(); + + private async Task AuditAsync(AuditType type, int userId, int objectId, string? message = null, string? parameters = null) + { + Guid userKey = await _userIdKeyResolver.GetAsync(userId); + + await _auditService.AddAsync( + type, + userKey, + objectId, + ItemObjectType.GetName(), + message, + parameters); + } +} diff --git a/src/Umbraco.Core/Services/ElementService.cs b/src/Umbraco.Core/Services/ElementService.cs index b3218f48e602..02765661e56e 100644 --- a/src/Umbraco.Core/Services/ElementService.cs +++ b/src/Umbraco.Core/Services/ElementService.cs @@ -158,11 +158,11 @@ protected override CancelableEnumerableObjectNotification Unpublishing protected override IStatefulNotification UnpublishedNotification(IElement content, EventMessages eventMessages) => new ElementUnpublishedNotification(content, eventMessages); - protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) - => new ElementDeletingVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + protected override RollingBackNotification RollingBackNotification(IElement target, EventMessages messages) + => new ElementRollingBackNotification(target, messages); - protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default) - => new ElementDeletedVersionsNotification(id, messages, specificVersion, deletePriorVersions, dateToRetain); + protected override RolledBackNotification RolledBackNotification(IElement target, EventMessages messages) + => new ElementRolledBackNotification(target, messages); #endregion } diff --git a/src/Umbraco.Core/Services/ElementVersionService.cs b/src/Umbraco.Core/Services/ElementVersionService.cs new file mode 100644 index 000000000000..c14f8b450b27 --- /dev/null +++ b/src/Umbraco.Core/Services/ElementVersionService.cs @@ -0,0 +1,44 @@ +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; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class ElementVersionService : ContentVersionServiceBase, IElementVersionService +{ + public ElementVersionService( + ILogger logger, + IElementVersionRepository contentVersionRepository, + IContentVersionCleanupPolicy contentVersionCleanupPolicy, + ICoreScopeProvider scopeProvider, + IEventMessagesFactory eventMessagesFactory, + IAuditService auditService, + ILanguageRepository languageRepository, + IEntityService entityService, + IElementService contentService, + IUserIdKeyResolver userIdKeyResolver) + : base( + logger, + contentVersionRepository, + contentVersionCleanupPolicy, + scopeProvider, + eventMessagesFactory, + auditService, + languageRepository, + entityService, + contentService, + userIdKeyResolver) + { + } + + protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Element; + + protected override DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ElementDeletingVersionsNotification(id, messages, specificVersion); + + protected override DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion) + => new ElementDeletedVersionsNotification(id, messages, specificVersion); +} diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 19552cd57d6a..da24e955decc 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -10,22 +10,6 @@ namespace Umbraco.Cms.Core.Services; /// public interface IContentService : IPublishableContentService { - #region Rollback - - /// - /// Rolls back the content to a specific version. - /// - /// The id of the content node. - /// The version id to roll back to. - /// An optional culture to roll back. - /// The identifier of the user who is performing the roll back. - /// - /// When no culture is specified, all cultures are rolled back. - /// - OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId); - - #endregion - #region Blueprints /// @@ -179,40 +163,6 @@ IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId /// The ancestor documents. IEnumerable GetAncestors(IContent content); - /// - /// Gets all versions of a document. - /// - /// The identifier of the document. - /// The document versions. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersions(int id); - - /// - /// Gets all versions of a document. - /// - /// The identifier of the document. - /// The number of versions to skip. - /// The number of versions to take. - /// The document versions. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersionsSlim(int id, int skip, int take); - - /// - /// Gets top versions of a document. - /// - /// The identifier of the document. - /// The number of top versions to get. - /// The version identifiers. - /// Versions are ordered with current first, then most recent first. - IEnumerable GetVersionIds(int id, int topRows); - - /// - /// Gets a version of a document. - /// - /// The version identifier. - /// The document version, or null if not found. - IContent? GetVersion(int versionId); - /// /// Gets root-level documents. /// diff --git a/src/Umbraco.Core/Services/IElementVersionService.cs b/src/Umbraco.Core/Services/IElementVersionService.cs new file mode 100644 index 000000000000..9a9524039862 --- /dev/null +++ b/src/Umbraco.Core/Services/IElementVersionService.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IElementVersionService +{ + /// + /// Removes historic content versions according to a policy. + /// + IReadOnlyCollection PerformContentVersionCleanup(DateTime asAtDate); + + ContentVersionMeta? Get(int versionId); + Task?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take); + Task> GetAsync(Guid versionId); + + Task> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey); + Task> RollBackAsync(Guid versionId, string? culture, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/IPublishableContentService.cs b/src/Umbraco.Core/Services/IPublishableContentService.cs index f0f7acd1acec..d590bb4a73c2 100644 --- a/src/Umbraco.Core/Services/IPublishableContentService.cs +++ b/src/Umbraco.Core/Services/IPublishableContentService.cs @@ -69,4 +69,50 @@ public interface IPublishableContentService : IContentServiceBase /// PublishResult Unpublish(TContent content, string? culture = "*", int userId = Constants.Security.SuperUserId); + + /// + /// Gets all versions of content. + /// + /// The identifier of the content. + /// The content versions. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersions(int id); + + /// + /// Gets all versions of content. + /// + /// The identifier of the content. + /// The number of versions to skip. + /// The number of versions to take. + /// The content versions. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersionsSlim(int id, int skip, int take); + + /// + /// Gets top versions of content. + /// + /// The identifier of the content. + /// The number of top versions to get. + /// The version identifiers. + /// Versions are ordered with current first, then most recent first. + IEnumerable GetVersionIds(int id, int topRows); + + /// + /// Gets a version of content. + /// + /// The version identifier. + /// The content version, or null if not found. + TContent? GetVersion(int versionId); + + /// + /// Rolls back the content to a specific version. + /// + /// The id of the content node. + /// The version id to roll back to. + /// An optional culture to roll back. + /// The identifier of the user who is performing the roll back. + /// + /// When no culture is specified, all cultures are rolled back. + /// + OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId); } diff --git a/src/Umbraco.Core/Services/PublishableContentServiceBase.cs b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs index 6ad1e90df5ab..0af01a7d818d 100644 --- a/src/Umbraco.Core/Services/PublishableContentServiceBase.cs +++ b/src/Umbraco.Core/Services/PublishableContentServiceBase.cs @@ -110,10 +110,69 @@ protected virtual PublishResult CommitDocumentChanges( protected abstract IStatefulNotification UnpublishedNotification(TContent content, EventMessages eventMessages); - protected abstract DeletingVersionsNotification DeletingVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default); + protected abstract RollingBackNotification RollingBackNotification(TContent target, EventMessages messages); - protected abstract DeletedVersionsNotification DeletedVersionsNotification(int id, EventMessages messages, int specificVersion = default, bool deletePriorVersions = false, DateTime dateToRetain = default); + protected abstract RolledBackNotification RolledBackNotification(TContent target, EventMessages messages); + #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 + TContent? content = GetById(id); + + // Get the version + TContent? version = GetVersion(versionId); + + // Good old 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 (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + var rollingBackNotification = RollingBackNotification(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(RolledBackNotification(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 #region Count diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 6f5b4b5c48eb..efa4794fccd9 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -89,8 +89,7 @@ internal static IUmbracoBuilder AddRepositories(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); - // TODO ELEMENTS: implement versioning - // builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs index b78538f349e5..4b3ab5156b86 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentDto.cs @@ -7,7 +7,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] [PrimaryKey("nodeId", AutoIncrement = false)] [ExplicitColumns] -public class DocumentDto +public class DocumentDto : INodeDto { public const string TableName = Constants.DatabaseSchema.Tables.Document; @@ -15,7 +15,7 @@ public class DocumentDto // Public constants to bind properties between DTOs public const string PublishedColumnName = "published"; - [Column("nodeId")] + [Column(INodeDto.NodeIdColumnName)] [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentDto))] public int NodeId { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs index f99a6676be6e..b97222bfd974 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/DocumentVersionDto.cs @@ -7,11 +7,11 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] [PrimaryKey("id", AutoIncrement = false)] [ExplicitColumns] -public class DocumentVersionDto +public class DocumentVersionDto : IContentVersionDto { public const string TableName = Constants.DatabaseSchema.Tables.DocumentVersion; - [Column("id")] + [Column(IContentVersionDto.IdColumnName)] [PrimaryKeyColumn(AutoIncrement = false)] [ForeignKey(typeof(ContentVersionDto))] [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_id_published", ForColumns = "id,published", IncludeColumns = "templateId")] @@ -22,7 +22,7 @@ public class DocumentVersionDto [ForeignKey(typeof(TemplateDto), Column = "nodeId")] public int? TemplateId { get; set; } - [Column("published")] + [Column(IContentVersionDto.PublishedColumnName)] [Index(IndexTypes.NonClustered, Name = "IX_" + TableName + "_published", ForColumns = "published", IncludeColumns = "id,templateId")] public bool Published { get; set; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs index b84974c6f52e..e3e19db81501 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementDto.cs @@ -7,9 +7,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] [PrimaryKey("nodeId", AutoIncrement = false)] [ExplicitColumns] -public sealed class ElementDto +public sealed class ElementDto : INodeDto { - private const string TableName = Constants.DatabaseSchema.Tables.Element; + internal const string TableName = Constants.DatabaseSchema.Tables.Element; [Column("nodeId")] [PrimaryKeyColumn(AutoIncrement = false)] diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs index 837fe9847b67..b7bfa6d9b340 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ElementVersionDto.cs @@ -7,7 +7,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; [TableName(TableName)] [PrimaryKey("id", AutoIncrement = false)] [ExplicitColumns] -public sealed class ElementVersionDto +public sealed class ElementVersionDto : IContentVersionDto { public const string TableName = Constants.DatabaseSchema.Tables.ElementVersion; diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs new file mode 100644 index 000000000000..c78bacd08e7d --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/IContentVersionDto.cs @@ -0,0 +1,17 @@ +using NPoco; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +// TODO ELEMENTS: split this into two interfaces - like "IEntityDto" and "IPublishedDto"? +public interface IContentVersionDto +{ + internal const string IdColumnName = "id"; + + internal const string PublishedColumnName = "published"; + + [Column(IdColumnName)] + int Id { get; } + + [Column(PublishedColumnName)] + bool Published { get; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs new file mode 100644 index 000000000000..1dcf1ae6977b --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/INodeDto.cs @@ -0,0 +1,11 @@ +using NPoco; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +public interface INodeDto +{ + internal const string NodeIdColumnName = "nodeId"; + + [Column(NodeIdColumnName)] + int NodeId { get; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs new file mode 100644 index 000000000000..1beafa947e39 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentVersionRepositoryBase.cs @@ -0,0 +1,223 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal abstract class ContentVersionRepositoryBase + where TContentDto : INodeDto + where TContentVersionDto : IContentVersionDto +{ + private readonly IScopeAccessor _scopeAccessor; + + protected abstract string ContentDtoTableName { get; } + + protected abstract string ContentVersionDtoTableName { get; } + + public ContentVersionRepositoryBase(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 GetContentVersionsEligibleForCleanup() + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + return []; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .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) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .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 + + List results = ambientScope.Database.Fetch(query); + EnsureUtcDates(results); + return results; + } + + private string GetQuotedSelectColumns(ISqlSyntaxProvider syntax) => + $@" +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "id", "versionId")}, +{syntax.ColumnWithAlias(ContentDtoTableName, "nodeId", "contentId")}, +{syntax.ColumnWithAlias(ContentDto.TableName, "contentTypeId", "contentTypeId")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "userId", "userId")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "versionDate", "versionDate")}, +{syntax.ColumnWithAlias(ContentVersionDtoTableName,"published", "currentPublishedVersion")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "current", "currentDraftVersion")}, +{syntax.ColumnWithAlias(ContentVersionDto.TableName, "preventCleanup", "preventCleanup")}, +{syntax.ColumnWithAlias(UserDto.TableName, "userName", "username")} +"; + + /// + public IReadOnlyCollection GetCleanupPolicies() + { + if (_scopeAccessor.AmbientScope is null) + { + return []; + } + + Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + return _scopeAccessor.AmbientScope.Database.Fetch(query); + } + + /// + public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null) + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + totalRecords = 0; + return []; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .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) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .LeftJoin() + .On(left => left.VersionId, right => right.Id) + .Where(x => x.NodeId == contentId); + + // TODO: If there's not a better way to write this then we need a better way to write this. + query = languageId.HasValue + ? query.Where(x => x.LanguageId == languageId.Value) + : query.WhereNull(x => x.LanguageId); + + query = query.OrderByDescending(x => x.Id); + + Page page = + ambientScope.Database.Page(pageIndex + 1, pageSize, query); + + totalRecords = page.TotalItems; + + List results = page.Items; + EnsureUtcDates(results); + return results; + } + + /// + /// + /// Deletes in batches of + /// + public void DeleteVersions(IEnumerable versionIds) + { + if (_scopeAccessor.AmbientScope is null) + { + return; + } + + foreach (IEnumerable 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. + */ + + Sql 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); + } + } + + /// + public void SetPreventCleanup(int versionId, bool preventCleanup) + { + if (_scopeAccessor.AmbientScope is null) + { + return; + } + + Sql? query = _scopeAccessor.AmbientScope.SqlContext.Sql() + .Update(x => x.Set(y => y.PreventCleanup, preventCleanup)) + .Where(x => x.Id == versionId); + + _scopeAccessor.AmbientScope?.Database.Execute(query); + } + + /// + public ContentVersionMeta? Get(int versionId) + { + IScope? ambientScope = _scopeAccessor.AmbientScope; + if (ambientScope is null) + { + return null; + } + + ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; + Sql query = ambientScope.SqlContext.Sql() + .Select(GetQuotedSelectColumns(syntax)) + .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) + .LeftJoin() + .On(left => left.Id, right => right.UserId) + .Where(x => x.Id == versionId); + + ContentVersionMeta result = ambientScope.Database.Single(query); + result.EnsureUtc(); + return result; + } + + private static void EnsureUtcDates(IEnumerable versions) + { + foreach (ContentVersionMeta version in versions) + { + version.EnsureUtc(); + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs index ba455bbf8520..d0707feff932 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DocumentVersionRepository.cs @@ -1,221 +1,17 @@ -using System; -using System.Data; -using System.Text; -using NPoco; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Infrastructure.Persistence.Dtos; -using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax; using Umbraco.Cms.Infrastructure.Scoping; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; -internal sealed class DocumentVersionRepository : IDocumentVersionRepository +internal sealed class DocumentVersionRepository : ContentVersionRepositoryBase, 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() - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - return []; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .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) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .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 - - List results = ambientScope.Database.Fetch(query); - EnsureUtcDates(results); - return results; - } - - private string GetQuotedSelectColumns(ISqlSyntaxProvider syntax) => - $@" -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "id", "versionId")}, -{syntax.ColumnWithAlias(DocumentDto.TableName, "nodeId", "contentId")}, -{syntax.ColumnWithAlias(ContentDto.TableName, "contentTypeId", "contentTypeId")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "userId", "userId")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "versionDate", "versionDate")}, -{syntax.ColumnWithAlias(DocumentVersionDto.TableName ,"published", "currentPublishedVersion")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "current", "currentDraftVersion")}, -{syntax.ColumnWithAlias(ContentVersionDto.TableName, "preventCleanup", "preventCleanup")}, -{syntax.ColumnWithAlias(UserDto.TableName, "userName", "username")} -"; - - /// - public IReadOnlyCollection GetCleanupPolicies() - { - if (_scopeAccessor.AmbientScope is null) - { - return []; - } - - Sql query = _scopeAccessor.AmbientScope.SqlContext.Sql(); - - query.Select() - .From(); - - return _scopeAccessor.AmbientScope.Database.Fetch(query); - } - - /// - public IEnumerable GetPagedItemsByContentId(int contentId, long pageIndex, int pageSize, out long totalRecords, int? languageId = null) - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - totalRecords = 0; - return []; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .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) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .LeftJoin() - .On(left => left.VersionId, right => right.Id) - .Where(x => x.NodeId == contentId); - - // TODO: If there's not a better way to write this then we need a better way to write this. - query = languageId.HasValue - ? query.Where(x => x.LanguageId == languageId.Value) - : query.WhereNull(x => x.LanguageId); - - query = query.OrderByDescending(x => x.Id); - - Page page = - ambientScope.Database.Page(pageIndex + 1, pageSize, query); - - totalRecords = page.TotalItems; - - List results = page.Items; - EnsureUtcDates(results); - return results; - } - - /// - /// - /// Deletes in batches of - /// - public void DeleteVersions(IEnumerable versionIds) - { - if (_scopeAccessor.AmbientScope is null) - { - return; - } - - foreach (IEnumerable 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. - */ - - Sql 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); - } - } - - /// - public void SetPreventCleanup(int versionId, bool preventCleanup) + public DocumentVersionRepository(IScopeAccessor scopeAccessor) + : base(scopeAccessor) { - if (_scopeAccessor.AmbientScope is null) - { - return; - } - - Sql? query = _scopeAccessor.AmbientScope.SqlContext.Sql() - .Update(x => x.Set(y => y.PreventCleanup, preventCleanup)) - .Where(x => x.Id == versionId); - - _scopeAccessor.AmbientScope?.Database.Execute(query); } - /// - public ContentVersionMeta? Get(int versionId) - { - IScope? ambientScope = _scopeAccessor.AmbientScope; - if (ambientScope is null) - { - return null; - } - - ISqlSyntaxProvider syntax = ambientScope.SqlContext.SqlSyntax; - Sql query = ambientScope.SqlContext.Sql() - .Select(GetQuotedSelectColumns(syntax)) - .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) - .LeftJoin() - .On(left => left.Id, right => right.UserId) - .Where(x => x.Id == versionId); - - ContentVersionMeta result = ambientScope.Database.Single(query); - result.EnsureUtc(); - return result; - } + protected override string ContentDtoTableName => DocumentDto.TableName; - private static void EnsureUtcDates(IEnumerable versions) - { - foreach (ContentVersionMeta version in versions) - { - version.EnsureUtc(); - } - } + protected override string ContentVersionDtoTableName => DocumentVersionDto.TableName; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs new file mode 100644 index 000000000000..ded4bfe4ccd7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ElementVersionRepository.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +internal sealed class ElementVersionRepository : ContentVersionRepositoryBase, IElementVersionRepository +{ + public ElementVersionRepository(IScopeAccessor scopeAccessor) + : base(scopeAccessor) + { + } + + protected override string ContentDtoTableName => ElementDto.TableName; + + protected override string ContentVersionDtoTableName => ElementVersionDto.TableName; +} diff --git a/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs index 12c54033c5a2..6417893e2ae5 100644 --- a/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ElementBuilder.cs @@ -256,16 +256,20 @@ public static Element CreateBasicElement(IContentType contentType, int id = 0) = .WithName("Element") .Build(); - public static Element CreateSimpleElement(IContentType contentType, string name = "Element", string? culture = null, string? segment = null) + public static Element CreateSimpleElement(IContentType contentType, string name = "Element", string? culture = null, + string? segment = null) => new ElementBuilder() .WithContentType(contentType) .WithName(name) - .WithPropertyValues(new - { - title = "This is the element title", - bodyText = "This is the element body text", - author = "Some One" - }) + .WithPropertyValues( + new + { + title = "This is the element title", + bodyText = "This is the element body text", + author = "Some One" + }, + culture, + segment) .Build(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs index d818c08b39a6..57cfc0fd5754 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentVersionRepositoryTest.cs @@ -46,7 +46,7 @@ public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesActiveVersions( using (ScopeProvider.CreateScope()) { var sut = new DocumentVersionRepository(ScopeAccessor); - var results = sut.GetDocumentVersionsEligibleForCleanup(); + var results = sut.GetContentVersionsEligibleForCleanup(); Assert.Multiple(() => { @@ -85,7 +85,7 @@ public void GetDocumentVersionsEligibleForCleanup_Always_ExcludesPinnedVersions( ScopeAccessor.AmbientScope.Database.Update("set preventCleanup = 1 where id in (1,3)"); var sut = new DocumentVersionRepository(ScopeAccessor); - var results = sut.GetDocumentVersionsEligibleForCleanup(); + var results = sut.GetContentVersionsEligibleForCleanup(); Assert.Multiple(() => { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs new file mode 100644 index 000000000000..3dae61f662b3 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/ElementVersionRepositoryTest.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; +using Umbraco.Cms.Infrastructure.Scoping; +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.Persistence.Repositories; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class ElementVersionRepositoryTest : UmbracoIntegrationTest +{ + public IContentTypeService ContentTypeService => GetRequiredService(); + + public IElementService ElementService => GetRequiredService(); + + [Test] + public void GetElementVersionsEligibleForCleanup_Always_ExcludesActiveVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + // At this point content has 2 versions, a draft version and a published version. + + ElementService.Publish(content, Array.Empty()); + // At this point content has 3 versions, a historic version, a draft version and a published version. + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository(ScopeAccessor); + var results = sut.GetContentVersionsEligibleForCleanup(); + + Assert.Multiple(() => + { + Assert.AreEqual(1, results.Count); + Assert.AreEqual(1, results.First().VersionId); + }); + } + } + + [Test] + public void GetElementVersionsEligibleForCleanup_Always_ExcludesPinnedVersions() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + // At this point content has 2 versions, a draft version and a published version. + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + // At this point content has 5 versions, 3 historic versions, a draft version and a published version. + + var allVersions = ElementService.GetVersions(content.Id); + Debug.Assert(allVersions.Count() == 5); // Sanity check + + using (var scope = ScopeProvider.CreateScope()) + { + ScopeAccessor.AmbientScope.Database.Update("set preventCleanup = 1 where id in (1,3)"); + + var sut = new ElementVersionRepository(ScopeAccessor); + var results = sut.GetContentVersionsEligibleForCleanup(); + + 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.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + ElementService.Publish(content, Array.Empty()); + using (var scope = ScopeProvider.CreateScope()) + { + var query = ScopeAccessor.AmbientScope.SqlContext.Sql(); + + query.Select() + .From(); + + var sut = new ElementVersionRepository(ScopeAccessor); + sut.DeleteVersions(new[] { 1, 2, 3 }); + + var after = ScopeAccessor.AmbientScope.Database.Fetch(query); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.Count); + Assert.True(after.All(x => x.Id > 3)); + }); + } + } + + + [Test] + public void GetPagedItemsByContentId_WithInvariantCultureContent_ReturnsPaginatedResults() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType); + ElementService.Save(content); + + ElementService.Publish(content, Array.Empty()); // Draft + Published + ElementService.Publish(content, Array.Empty()); // New Draft + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository((IScopeAccessor)ScopeProvider); + var page1 = sut.GetPagedItemsByContentId(content.Id, 0, 2, out var page1Total); + var page2 = sut.GetPagedItemsByContentId(content.Id, 1, 2, out var page2Total); + + Assert.Multiple(() => + { + Assert.AreEqual(2, page1.Count()); + Assert.AreEqual(3, page1Total); + + Assert.AreEqual(1, page2.Count()); + Assert.AreEqual(3, page2Total); + }); + } + } + + [Test] + public void GetPagedItemsByContentId_WithVariantCultureContent_ReturnsPaginatedResults() + { + var contentType = ContentTypeBuilder.CreateSimpleElementType(); + contentType.Variations = ContentVariation.Culture; + foreach (var propertyType in contentType.PropertyTypes) + { + propertyType.Variations = ContentVariation.Culture; + } + ContentTypeService.Save(contentType); + + var content = ElementBuilder.CreateSimpleElement(contentType, "foo", culture: "en-US"); + content.SetCultureName("foo", "en-US"); + + ElementService.Save(content); + ElementService.Publish(content, new[] { "en-US" }); // Draft + Published + ElementService.Publish(content, new[] { "en-US" }); // New Draft + + using (ScopeProvider.CreateScope()) + { + var sut = new ElementVersionRepository((IScopeAccessor)ScopeProvider); + var page1 = sut.GetPagedItemsByContentId(content.Id, 0, 2, out var page1Total, 1); + var page2 = sut.GetPagedItemsByContentId(content.Id, 1, 2, out var page2Total, 1); + + Assert.Multiple(() => + { + Assert.AreEqual(2, page1.Count()); + Assert.AreEqual(3, page1Total); + + Assert.AreEqual(1, page2.Count()); + Assert.AreEqual(3, page2Total); + }); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs new file mode 100644 index 000000000000..e06fa03b69fd --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ElementVersionCleanupServiceTest.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +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 ElementVersionCleanupServiceTest : UmbracoIntegrationTest +{ + public IContentTypeService ContentTypeService => GetRequiredService(); + + public IElementService ElementService => GetRequiredService(); + + public IElementVersionService ElementVersionService => GetRequiredService(); + + [Test] + public void PerformElementVersionCleanup_WithNoKeepPeriods_DeletesEverythingExceptActive() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Kill all historic + elementType.HistoryCleanup.PreventCleanup = false; + elementType.HistoryCleanup.KeepAllVersionsNewerThanDays = 0; + elementType.HistoryCleanup.KeepLatestVersionPerDayForDays = 0; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + for (var i = 0; i < 10; i++) + { + ElementService.Publish(element, Array.Empty()); + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(2, after.ContentVersions); // current draft, current published + Assert.AreEqual(2, after.ElementVersions); + Assert.AreEqual(6, after.PropertyData); // CreateSimpleContentType = 3 props + }); + } + + [Test] + public void PerformElementVersionCleanup_WithPreventCleanup_DeletesNothing() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Retain all historic + elementType.HistoryCleanup.PreventCleanup = true; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + for (var i = 0; i < 10; i++) + { + ElementService.Publish(element, Array.Empty()); + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + // no changes + Assert.Multiple(() => + { + Assert.AreEqual(before.ContentVersions, after.ContentVersions); + Assert.AreEqual(before.ElementVersions, after.ElementVersions); + Assert.AreEqual(before.PropertyData, after.PropertyData); + }); + } + + [Test] + public async Task PerformElementVersionCleanup_CanPreventCleanupOfSpecificVersions() + { + var elementType = ContentTypeBuilder.CreateSimpleElementType(); + + // Kill all historic + elementType.HistoryCleanup.PreventCleanup = false; + elementType.HistoryCleanup.KeepAllVersionsNewerThanDays = 0; + elementType.HistoryCleanup.KeepLatestVersionPerDayForDays = 0; + + ContentTypeService.Save(elementType); + + var element = ElementBuilder.CreateSimpleElement(elementType); + ElementService.Save(element); + ElementService.Publish(element, Array.Empty()); + + var retainedVersionIds = new List(); + for (var i = 0; i < 10; i++) + { + var result = ElementService.Publish(element, Array.Empty()); + if (i < 5) + { + retainedVersionIds.Add(result.Content.VersionId); + await ElementVersionService.SetPreventCleanupAsync(retainedVersionIds.Last().ToGuid(), true, Constants.Security.SuperUserKey); + } + } + + var before = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(12, before.ContentVersions); // 10 historic + current draft + current published + Assert.AreEqual(12, before.ElementVersions); + Assert.AreEqual(12 * 3, before.PropertyData); // CreateSimpleContentType = 3 props + }); + + ElementVersionService.PerformContentVersionCleanup(DateTime.UtcNow.AddHours(1)); + + var after = GetReport(); + + Assert.Multiple(() => + { + Assert.AreEqual(7, after.ContentVersions); // current draft, current published + 5 retained versions + Assert.AreEqual(7, after.ElementVersions); + Assert.AreEqual(7 * 3, after.PropertyData); // CreateSimpleContentType = 3 props + }); + + var allVersions = await ElementVersionService.GetPagedContentVersionsAsync(element.Key, null, 0, 1000); + Assert.IsTrue(allVersions.Success); + Assert.AreEqual(7, allVersions.Result.Total); + + var allVersionIds = allVersions.Result.Items.Select(item => item.VersionId).ToArray(); + Assert.AreNotEqual(element.VersionId, element.PublishedVersionId); + Assert.Contains(element.VersionId, allVersionIds); + Assert.Contains(element.PublishedVersionId, allVersionIds); + foreach (var retainedVersionId in retainedVersionIds) + { + Assert.Contains(retainedVersionId, allVersionIds); + } + } + + private Report GetReport() + { + using (var scope = ScopeProvider.CreateScope(autoComplete: true)) + { + // SQL CE is fun! + var contentVersions = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoContentVersion"); + var elementVersions = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoElementVersion"); + var propertyData = + ScopeAccessor.AmbientScope.Database.Single(@"select count(1) from umbracoPropertyData"); + + return new Report + { + ContentVersions = contentVersions, + ElementVersions = elementVersions, + PropertyData = propertyData + }; + } + } + + private class Report + { + public int ContentVersions { get; set; } + + public int ElementVersions { get; set; } + + public int PropertyData { get; set; } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs index d4ae75f488b7..cdffe5408ed5 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/ContentVersionCleanupServiceTest.cs @@ -26,7 +26,7 @@ public void PerformContentVersionCleanup_Always_RespectsDeleteRevisionsCancellat DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator.Setup(x => x.PublishCancelable(It.IsAny())) @@ -58,7 +58,7 @@ public void PerformContentVersionCleanup_Always_FiresDeletedVersionsForEachDelet DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -84,7 +84,7 @@ public void PerformContentVersionCleanup_Always_ReturnsReportOfDeletedItems( DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -111,7 +111,7 @@ public void PerformContentVersionCleanup_Always_AdheresToCleanupPolicy( DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator @@ -146,7 +146,7 @@ public void PerformContentVersionCleanup_HasVersionsToDelete_CallsDeleteOnReposi DateTime aDateTime, ContentVersionService sut) { - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(someHistoricVersions); eventAggregator diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs index 6233e220413c..1e675f809567 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/DefaultContentVersionCleanupPolicyTest.cs @@ -42,7 +42,7 @@ public void Apply_AllOlderThanKeepSettings_AllVersionsReturned( documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -78,7 +78,7 @@ public void Apply_OverlappingKeepSettings_KeepAllVersionsNewerThanDaysTakesPrior documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -122,7 +122,7 @@ public void Apply_WithinInKeepLatestPerDay_ReturnsSinglePerContentPerDay( documentVersionRepository.Setup(x => x.GetCleanupPolicies()) .Returns(Array.Empty()); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -175,7 +175,7 @@ public void Apply_HasOverridePolicy_RespectsPreventCleanup( new() { ContentTypeId = 2, PreventCleanup = true }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -218,7 +218,7 @@ public void Apply_HasOverridePolicy_RespectsKeepAll( new() { ContentTypeId = 2, PreventCleanup = false, KeepAllVersionsNewerThanDays = 3 }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList(); @@ -266,7 +266,7 @@ public void Apply_HasOverridePolicy_RespectsKeepLatest( new() { ContentTypeId = 2, PreventCleanup = false, KeepLatestVersionPerDayForDays = 3 }, }); - documentVersionRepository.Setup(x => x.GetDocumentVersionsEligibleForCleanup()) + documentVersionRepository.Setup(x => x.GetContentVersionsEligibleForCleanup()) .Returns(historicItems); var results = sut.Apply(DateTime.Today, historicItems).ToList();