V14: Unpublish multiple cultures (#15789)

* Refactor to accept multiple cultures

* Add test

* Move logic into service

* Remember to complete scope

* Move scope creation.

* Add test

* Close scope on early return

* Handle invalid cultures with bad request

* Handle valid cultures based on content

* Change return type if a culture is required

* Refactor content publishing service to have 1 unpublish method

* Update tests

* Return better error

* Scope completes

---------

Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
Nikolaj Geisle
2024-02-29 14:38:47 +01:00
committed by GitHub
parent 10c18e4162
commit b8577e3af2
11 changed files with 194 additions and 23 deletions

View File

@@ -91,6 +91,14 @@ public abstract class DocumentControllerBase : ContentControllerBase
.WithTitle("Culture missing")
.WithDetail("A culture needs to be specified to execute the operation.")
.Build()),
ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish invariant when variant")
.WithDetail("Cannot publish invariant culture when the document varies by culture.")
.Build()),
ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder
.WithTitle("Cannot publish variant when not variant.")
.WithDetail("Cannot publish a given culture when the document is invariant.")
.Build()),
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder
.WithTitle("Concurrency violation detected")
.WithDetail("An attempt was made to publish a version older than the latest version.")

View File

@@ -39,13 +39,12 @@ public class UnpublishDocumentController : DocumentControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Unpublish(Guid id, UnpublishDocumentRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(
ActionUnpublish.ActionLetter,
id,
requestModel.Culture is not null ? requestModel.Culture.Yield() : Enumerable.Empty<string>()),
requestModel.Cultures ?? Enumerable.Empty<string>()),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
@@ -55,8 +54,9 @@ public class UnpublishDocumentController : DocumentControllerBase
Attempt<ContentPublishingOperationStatus> attempt = await _contentPublishingService.UnpublishAsync(
id,
requestModel.Culture,
requestModel.Cultures,
CurrentUserKey(_backOfficeSecurityAccessor));
return attempt.Success
? Ok()
: DocumentPublishingOperationStatusResult(attempt.Result);

View File

@@ -39602,8 +39602,12 @@
"UnpublishDocumentRequestModel": {
"type": "object",
"properties": {
"culture": {
"type": "string",
"cultures": {
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
},

View File

@@ -2,5 +2,5 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class UnpublishDocumentRequestModel
{
public string? Culture { get; set; }
public ISet<string>? Cultures { get; set; }
}

View File

@@ -380,7 +380,7 @@ public static class ContentRepositoryExtensions
var keepProcessing = true;
if (culture == "*")
if (culture == "*" || (content.ContentType.VariesByCulture() is false && culture is null))
{
// all cultures
content.ClearPublishInfos();

View File

@@ -197,24 +197,123 @@ internal sealed class ContentPublishingService : IContentPublishingService
: Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult);
}
/// <inheritdoc />
public async Task<Attempt<ContentPublishingOperationStatus>> UnpublishAsync(Guid key, string? culture, Guid userKey)
/// <inheritdoc/>
public async Task<Attempt<ContentPublishingOperationStatus>> UnpublishAsync(Guid key, ISet<string>? cultures, Guid userKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IContent? content = _contentService.GetById(key);
if (content is null)
{
scope.Complete();
return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound);
}
var userId = await _userIdKeyResolver.GetAsync(userKey);
PublishResult result = _contentService.Unpublish(content, culture ?? "*", userId);
Attempt<ContentPublishingOperationStatus> attempt;
if (cultures is null)
{
attempt = await UnpublishInvariantAsync(
content,
userId);
scope.Complete();
return attempt;
}
if (cultures.Any() is false)
{
scope.Complete();
return Attempt<ContentPublishingOperationStatus>.Fail(ContentPublishingOperationStatus.CultureMissing);
}
if (cultures.Contains("*"))
{
attempt = await UnpublishAllCulturesAsync(
content,
userId);
}
else
{
attempt = await UnpublishMultipleCultures(
content,
cultures,
userId);
}
scope.Complete();
return attempt;
}
private Task<Attempt<ContentPublishingOperationStatus>> UnpublishAllCulturesAsync(IContent content, int userId)
{
if (content.ContentType.VariesByCulture() is false)
{
return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant));
}
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
PublishResult result = _contentService.Unpublish(content, "*", userId);
scope.Complete();
ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result);
return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success
return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success
? Attempt.Succeed(ToContentPublishingOperationStatus(result))
: Attempt.Fail(ToContentPublishingOperationStatus(result));
: Attempt.Fail(ToContentPublishingOperationStatus(result)));
}
private async Task<Attempt<ContentPublishingOperationStatus>> UnpublishMultipleCultures(IContent content, ISet<string> cultures, int userId)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
if (content.ContentType.VariesByCulture() is false)
{
scope.Complete();
return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant);
}
var validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray();
foreach (var culture in cultures)
{
if (validCultures.Contains(culture, StringComparer.InvariantCultureIgnoreCase) is false)
{
scope.Complete();
return Attempt.Fail(ContentPublishingOperationStatus.InvalidCulture);
}
PublishResult result = _contentService.Unpublish(content, culture, userId);
ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result);
if (contentPublishingOperationStatus is not ContentPublishingOperationStatus.Success)
{
return Attempt.Fail(ToContentPublishingOperationStatus(result));
}
}
scope.Complete();
return Attempt.Succeed(ContentPublishingOperationStatus.Success);
}
private Task<Attempt<ContentPublishingOperationStatus>> UnpublishInvariantAsync(IContent content, int userId)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
if (content.ContentType.VariesByCulture())
{
return Task.FromResult(Attempt.Fail(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant));
}
PublishResult result = _contentService.Unpublish(content, null, userId);
scope.Complete();
ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result);
return Task.FromResult(contentPublishingOperationStatus is ContentPublishingOperationStatus.Success
? Attempt.Succeed(ToContentPublishingOperationStatus(result))
: Attempt.Fail(ToContentPublishingOperationStatus(result)));
}
private static ContentPublishingOperationStatus ToContentPublishingOperationStatus(PublishResult publishResult)

View File

@@ -1,5 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentPublishing;
using Umbraco.Cms.Core.Models.ContentPublishing;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -26,11 +25,11 @@ public interface IContentPublishingService
Task<Attempt<ContentPublishingBranchResult, ContentPublishingOperationStatus>> PublishBranchAsync(Guid key, IEnumerable<string> cultures, bool force, Guid userKey);
/// <summary>
/// Unpublishes a single content item.
/// Unpublishes multiple cultures of a single content item.
/// </summary>
/// <param name="key">The key of the root content.</param>
/// <param name="culture">The culture to unpublish. Use null to unpublish all cultures.</param>
/// <param name="cultures">The cultures to unpublish. Use null to unpublish all cultures.</param>
/// <param name="userKey">The identifier of the user performing the operation.</param>
/// <returns>Status of the publish operation.</returns>
Task<Attempt<ContentPublishingOperationStatus>> UnpublishAsync(Guid key, string? culture, Guid userKey);
Task<Attempt<ContentPublishingOperationStatus>> UnpublishAsync(Guid key, ISet<string>? cultures, Guid userKey);
}

View File

@@ -441,7 +441,7 @@ public interface IContentService : IContentServiceBase<IContent>
/// empty. If the content type is invariant, then culture can be either '*' or null or empty.
/// </para>
/// </remarks>
PublishResult Unpublish(IContent content, string culture = "*", int userId = Constants.Security.SuperUserId);
PublishResult Unpublish(IContent content, string? culture = "*", int userId = Constants.Security.SuperUserId);
/// <summary>
/// Gets a value indicating whether a document is path-publishable.

View File

@@ -14,6 +14,8 @@ public enum ContentPublishingOperationStatus
CultureAwaitingRelease,
InTrash,
InvalidCulture,
CannotPublishInvariantWhenVariant,
CannotPublishVariantWhenNotVariant,
CultureMissing,
PathNotPublished,
ConcurrencyViolation,

View File

@@ -145,7 +145,7 @@ public partial class ContentPublishingServiceTests
Assert.IsTrue(content.PublishedCultures.InvariantContains(langEn.IsoCode));
Assert.IsTrue(content.PublishedCultures.InvariantContains(langDa.IsoCode));
var unpublishResult = await ContentPublishingService.UnpublishAsync(content.Key, "*", Constants.Security.SuperUserKey);
var unpublishResult = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string>() { "*" }, Constants.Security.SuperUserKey);
Assert.IsTrue(unpublishResult.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, unpublishResult.Result);

View File

@@ -3,6 +3,7 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
@@ -107,7 +108,7 @@ public partial class ContentPublishingServiceTests
await ContentPublishingService.PublishAsync(content.Key, MakeModel(new HashSet<string>() { langEn.IsoCode, langDa.IsoCode }), Constants.Security.SuperUserKey);
VerifyIsPublished(content.Key);
var result = await ContentPublishingService.UnpublishAsync(content.Key, langEn.IsoCode, Constants.Security.SuperUserKey);
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string> { langEn.IsoCode }, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsPublished(content.Key);
@@ -133,7 +134,7 @@ public partial class ContentPublishingServiceTests
await ContentPublishingService.PublishAsync(content.Key, MakeModel(new HashSet<string>() { langEn.IsoCode, langDa.IsoCode }), Constants.Security.SuperUserKey);
VerifyIsPublished(content.Key);
var result = await ContentPublishingService.UnpublishAsync(content.Key, null, Constants.Security.SuperUserKey);
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string>(){"*"}, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsNotPublished(content.Key);
@@ -142,6 +143,64 @@ public partial class ContentPublishingServiceTests
Assert.AreEqual(0, content.PublishedCultures.Count());
}
[Test]
public async Task Can_Unpublish_All_Cultures_With_Multiple_Cultures_Method()
{
var (langEn, langDa, contentType) = await SetupVariantTest();
IContent content = new ContentBuilder()
.WithContentType(contentType)
.WithCultureName(langEn.IsoCode, "EN root")
.WithCultureName(langDa.IsoCode, "DA root")
.Build();
content.SetValue("title", "EN title", culture: langEn.IsoCode);
content.SetValue("title", "DA title", culture: langDa.IsoCode);
ContentService.Save(content);
await ContentPublishingService.PublishAsync(content.Key, MakeModel(new HashSet<string>() { langEn.IsoCode, langDa.IsoCode }), Constants.Security.SuperUserKey);
VerifyIsPublished(content.Key);
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string>() { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsNotPublished(content.Key);
content = ContentService.GetById(content.Key)!;
Assert.AreEqual(0, content.PublishedCultures.Count());
}
[Test]
public async Task Can_Unpublish_Multiple_Cultures()
{
var (langEn, langDa, contentType) = await SetupVariantTest();
var langSe = new LanguageBuilder()
.WithCultureInfo("sv-SE")
.Build();
await LanguageService.CreateAsync(langSe, Constants.Security.SuperUserKey);
IContent content = new ContentBuilder()
.WithContentType(contentType)
.WithCultureName(langEn.IsoCode, "EN root")
.WithCultureName(langDa.IsoCode, "DA root")
.WithCultureName(langSe.IsoCode, "SE root")
.Build();
content.SetValue("title", "EN title", culture: langEn.IsoCode);
content.SetValue("title", "DA title", culture: langDa.IsoCode);
content.SetValue("title", "SE title", culture: langSe.IsoCode);
ContentService.Save(content);
await ContentPublishingService.PublishAsync(content.Key, MakeModel(new HashSet<string>() { langEn.IsoCode, langDa.IsoCode, langSe.IsoCode }), Constants.Security.SuperUserKey);
VerifyIsPublished(content.Key);
content = ContentService.GetById(content.Key)!;
Assert.AreEqual(3, content.PublishedCultures.Count());
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string> { langDa.IsoCode, langSe.IsoCode }, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsPublished(content.Key);
content = ContentService.GetById(content.Key)!;
Assert.AreEqual(1, content.PublishedCultures.Count());
}
[Test]
public async Task Can_Unpublish_All_Cultures_By_Unpublishing_Mandatory_Culture()
{
@@ -161,7 +220,7 @@ public partial class ContentPublishingServiceTests
content = ContentService.GetById(content.Key)!;
Assert.AreEqual(2, content.PublishedCultures.Count());
var result = await ContentPublishingService.UnpublishAsync(content.Key, langEn.IsoCode, Constants.Security.SuperUserKey);
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string> { langEn.IsoCode }, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsNotPublished(content.Key);
@@ -191,7 +250,7 @@ public partial class ContentPublishingServiceTests
content = ContentService.GetById(content.Key)!;
Assert.AreEqual(2, content.PublishedCultures.Count());
var result = await ContentPublishingService.UnpublishAsync(content.Key, langDa.IsoCode, Constants.Security.SuperUserKey);
var result = await ContentPublishingService.UnpublishAsync(content.Key, new HashSet<string>() { langDa.IsoCode }, Constants.Security.SuperUserKey);
Assert.IsTrue(result.Success);
Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result);
VerifyIsPublished(content.Key);