Skip to content

Commit

Permalink
Publishing in the Management API (#14774)
Browse files Browse the repository at this point in the history
* make CoreScopeProvider available for derived classes

* Create publish controller

* Add publish functionality

* Remove unneeded using

* Implement publish for multiple cultures

* support multiple cultures in controler

* Dont validate properties

* Refactor to use PublishingOperationStatus

* refactor to use proper publish async methods

* Refactor publish logic into own service

* Commit some demo code

* Add notes about what errors can happen when publishing

* Rework ContentPublishingService and introduce explicit Publish and PublishBranch methods in ContentService

* Fix merge

* Allow the publishing strategy to do its job

* Improved check for unsaved changes

* Make the old content controller work (as best possible)

* Remove SaveAndPublish (SaveAndPublishBranch) from all tests

* Proper guards for invalid cultures when publishing

* Fix edge cases for property validation and content unpublishing + add unpublishing to ContentPublishingService

* Clear out a few TODOs - we'll accept the behavior for now

* Unpublish controller

* Fix merge

* Fix branch publish notifications

* Added extra test for publishing unpublished cultures and added FIXME comments for when we fix the state of published cultures in content

---------

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
Co-authored-by: Zeegaan <nge@umbraco.dk>
  • Loading branch information
3 people authored Nov 22, 2023
1 parent 42dd2da commit 012b43a
Show file tree
Hide file tree
Showing 41 changed files with 2,012 additions and 562 deletions.
63 changes: 62 additions & 1 deletion src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperat
.WithDetail("A notification handler prevented the content operation.")
.Build()),
ContentEditingOperationStatus.ContentTypeNotFound => NotFound(new ProblemDetailsBuilder()
.WithTitle("Cancelled by notification")
.WithTitle("The requested content could not be found")
.Build()),
ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content type culture variance mismatch")
Expand Down Expand Up @@ -66,6 +66,67 @@ protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperat
.Build()),
};

protected IActionResult ContentPublishingOperationStatusResult(ContentPublishingOperationStatus status) =>
status switch
{
ContentPublishingOperationStatus.ContentNotFound => NotFound(new ProblemDetailsBuilder()
.WithTitle("The requested content could not be found")
.Build()),
ContentPublishingOperationStatus.CancelledByEvent => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Publish cancelled by event")
.WithDetail("The publish operation was cancelled by an event.")
.Build()),
ContentPublishingOperationStatus.ContentInvalid => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid content")
.WithDetail("The specified content had an invalid configuration.")
.Build()),
ContentPublishingOperationStatus.NothingToPublish => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Nothing to publish")
.WithDetail("None of the specified cultures needed publishing.")
.Build()),
ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Mandatory culture missing")
.WithDetail("Must include all mandatory cultures when publishing.")
.Build()),
ContentPublishingOperationStatus.HasExpired => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content expired")
.WithDetail("Could not publish the content because it was expired.")
.Build()),
ContentPublishingOperationStatus.CultureHasExpired => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content culture expired")
.WithDetail("Could not publish the content because some of the specified cultures were expired.")
.Build()),
ContentPublishingOperationStatus.AwaitingRelease => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content awaiting release")
.WithDetail("Could not publish the content because it was awaiting release.")
.Build()),
ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content culture awaiting release")
.WithDetail("Could not publish the content because some of the specified cultures were awaiting release.")
.Build()),
ContentPublishingOperationStatus.InTrash => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Content in the recycle bin")
.WithDetail("Could not publish the content because it was in the recycle bin.")
.Build()),
ContentPublishingOperationStatus.PathNotPublished => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Parent not published")
.WithDetail("Could not publish the content because its parent was not published.")
.Build()),
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Concurrency violation detected")
.WithDetail("An attempt was made to publish a version older than the latest version.")
.Build()),
ContentPublishingOperationStatus.UnsavedChanges => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Unsaved changes")
.WithDetail("Could not publish the content because it had unsaved changes. Make sure to save all changes before attempting a publish.")
.Build()),
ContentPublishingOperationStatus.Failed => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Publish or unpublish failed")
.WithDetail("An unspecified error occurred while (un)publishing. Please check the logs for additional information.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."),
};

protected IActionResult ContentCreatingOperationStatusResult(ContentCreatingOperationStatus status) =>
status switch
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
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.Document;

public class PublishDocumentController : DocumentControllerBase
{
private readonly IContentPublishingService _contentPublishingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public PublishDocumentController(IContentPublishingService contentPublishingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_contentPublishingService = contentPublishingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPut("{id:guid}/publish")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Publish(Guid id, PublishDocumentRequestModel requestModel)
{
Attempt<ContentPublishingOperationStatus> attempt = await _contentPublishingService.PublishAsync(
id,
requestModel.Cultures,
CurrentUserKey(_backOfficeSecurityAccessor));
return attempt.Success
? Ok()
: ContentPublishingOperationStatusResult(attempt.Result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
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.Document;

public class PublishDocumentWithDescendantsController : DocumentControllerBase
{
private readonly IContentPublishingService _contentPublishingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public PublishDocumentWithDescendantsController(IContentPublishingService contentPublishingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_contentPublishingService = contentPublishingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPut("{id:guid}/publish-with-descendants")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> PublishWithDescendants(Guid id, PublishDocumentWithDescendantsRequestModel requestModel)
{
Attempt<IDictionary<Guid, ContentPublishingOperationStatus>> attempt = await _contentPublishingService.PublishBranchAsync(
id,
requestModel.Cultures,
requestModel.IncludeUnpublishedDescendants,
CurrentUserKey(_backOfficeSecurityAccessor));

// FIXME: when we get to implement proper validation handling, this should return a collection of status codes by key (based on attempt.Result)
return attempt.Success
? Ok()
: ContentPublishingOperationStatusResult(
attempt.Result?.Values.FirstOrDefault(r => r is not ContentPublishingOperationStatus.Success)
?? throw new NotSupportedException("The attempt was not successful - at least one result value should be unsuccessful too"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
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.Document;

public class UnpublishDocumentController : DocumentControllerBase
{
private readonly IContentPublishingService _contentPublishingService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public UnpublishDocumentController(IContentPublishingService contentPublishingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_contentPublishingService = contentPublishingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPut("{id:guid}/unpublish")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Unpublish(Guid id, UnpublishDocumentRequestModel requestModel)
{
Attempt<ContentPublishingOperationStatus> attempt = await _contentPublishingService.UnpublishAsync(
id,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return attempt.Success
? Ok()
: ContentPublishingOperationStatusResult(attempt.Result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;

public class PublishDocumentRequestModel
{
public required IEnumerable<string> Cultures { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;

public class PublishDocumentWithDescendantsRequestModel : PublishDocumentRequestModel
{
public bool IncludeUnpublishedDescendants { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;

public class UnpublishDocumentRequestModel
{
public string? Culture { get; set; }
}
1 change: 1 addition & 0 deletions src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ private void AddCoreServices()
Services.AddUnique<ITagService, TagService>();
Services.AddUnique<IContentService, ContentService>();
Services.AddUnique<IContentEditingService, ContentEditingService>();
Services.AddUnique<IContentPublishingService, ContentPublishingService>();
Services.AddUnique<IContentCreatingService, ContentCreatingService>();
Services.AddUnique<IContentVersionCleanupPolicy, DefaultContentVersionCleanupPolicy>();
Services.AddUnique<IMemberService, MemberService>();
Expand Down
11 changes: 6 additions & 5 deletions src/Umbraco.Core/Services/ContentEditingServiceBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IDataTypeService _dataTypeService;
private readonly ILogger<ContentEditingServiceBase<TContent, TContentType, TContentService, TContentTypeService>> _logger;
private readonly ICoreScopeProvider _scopeProvider;
private readonly ITreeEntitySortingService _treeEntitySortingService;
private readonly IUserIdKeyResolver _userIdKeyResolver;

Expand All @@ -35,9 +34,9 @@ protected ContentEditingServiceBase(
_propertyEditorCollection = propertyEditorCollection;
_dataTypeService = dataTypeService;
_logger = logger;
_scopeProvider = scopeProvider;
_userIdKeyResolver = userIdKeyResolver;
_treeEntitySortingService = treeEntitySortingService;
CoreScopeProvider = scopeProvider;
ContentService = contentService;
ContentTypeService = contentTypeService;
}
Expand All @@ -56,6 +55,8 @@ protected ContentEditingServiceBase(

protected abstract ContentEditingOperationStatus Sort(IEnumerable<TContent> items, int userId);

protected ICoreScopeProvider CoreScopeProvider { get; }

protected TContentService ContentService { get; }

protected TContentTypeService ContentTypeService { get; }
Expand Down Expand Up @@ -110,7 +111,7 @@ protected async Task<Attempt<ContentEditingOperationStatus>> MapUpdate(TContent
// helper method to perform move-to-recycle-bin and delete for content as they are very much handled in the same way
private async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleDeletionAsync(Guid key, Guid userKey, bool mustBeTrashed, Func<TContent, int, OperationResult?> performDelete)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
TContent? content = ContentService.GetById(key);
if (content == null)
{
Expand All @@ -135,7 +136,7 @@ protected async Task<Attempt<ContentEditingOperationStatus>> MapUpdate(TContent

protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleMoveAsync(Guid key, Guid? parentKey, Guid userKey)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
TContent? content = ContentService.GetById(key);
if (content is null)
{
Expand Down Expand Up @@ -172,7 +173,7 @@ protected async Task<Attempt<ContentEditingOperationStatus>> MapUpdate(TContent

protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleCopyAsync(Guid key, Guid? parentKey, bool relateToOriginal, bool includeDescendants, Guid userKey)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
TContent? content = ContentService.GetById(key);
if (content is null)
{
Expand Down
Loading

0 comments on commit 012b43a

Please sign in to comment.