Skip to content

Commit 6f0fd8f

Browse files
lauranetoclaude
andauthored
Elements: Add reference settings support (#21601)
* Elements: Add DisableDeleteWhenReferenced support and fix delete notifications - Add DisableDeleteWhenReferenced check to ElementContainerService delete operations - Fire ElementDeletedNotification and EntityContainerDeletedNotification per item during descendant deletion - Fix potential infinite loop when items are skipped due to being referenced - Simplify EmptyRecycleBinAsync to use DeleteDescendantsLocked directly - Use path descending ordering for consistent deletion order (children before parents) - Add test for descendant delete notifications * Elements: Fix EmptyRecycleBin pagination with DisableDeleteWhenReferenced When DisableDeleteWhenReferenced is enabled and some items are skipped, the standard skip/take pagination breaks. This change: - Adds SqlLessThan/SqlGreaterThan SQL expression extensions for string comparison in LINQ queries - Uses path-based cursor pagination instead of skip/take - Tracks protected paths to prevent deleting containers that have referenced descendants - Adds ElementRecycleBin to UmbracoObjectTypes enum * Tests: Add DisableUnpublishWhenReferenced tests for elements Verify that DisableUnpublishWhenReferenced works correctly for elements (inherited from ContentPublishingServiceBase): - Cannot unpublish an element that is being referenced - Can unpublish an element that is doing the referencing * Elements: Remove redundant Trashed filter from DeleteDescendantsLocked The Trashed filter was redundant because: - EmptyRecycleBinAsync only operates on items under the recycle bin root - DeleteFromRecycleBinAsync requires containers to be trashed, and all descendants are marked as trashed when moved to recycle bin Removing the filter simplifies the query and handles edge cases better. * Elements: Add proper ProblemDetails responses for publish/unpublish endpoints Move ContentPublishingOperationStatusResult from DocumentControllerBase to ContentControllerBase so it can be shared. Add ElementPublishingOperationStatusResult to ElementControllerBase and update PublishElementController and UnpublishElementController to return proper error responses instead of empty BadRequest() when operations fail (e.g., when DisableUnpublishWhenReferenced is enabled). * Refactor: Use abstract EntityName for content controller error messages Replace hardcoded "document" terminology in shared ContentControllerBase error messages with an abstract EntityName property, so each subclass (document, element, media, member, etc.) provides context-appropriate error messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: Check DisableUnpublishWhenReferenced when moving elements to recycle bin ElementEditingService.MoveToRecycleBinAsync was missing the reference check that ContentEditingService already performs for documents. This allowed referenced elements to be moved to the recycle bin even when DisableUnpublishWhenReferenced was enabled. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Prevent moving container to recycle bin when descendants are referenced Add server-side validation to ElementContainerService.MoveToRecycleBinAsync that checks for referenced descendants when DisableUnpublishWhenReferenced is enabled. Uses ITrackedReferencesService.GetPagedDescendantsInReferencesAsync as an upfront check before any move processing begins. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ebcb996 commit 6f0fd8f

25 files changed

+678
-182
lines changed

src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using Microsoft.AspNetCore.Http;
22
using Microsoft.AspNetCore.Mvc;
3+
using Umbraco.Cms.Api.Management.ViewModels.Document;
34
using Umbraco.Cms.Core.Models.ContentEditing;
45
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
6+
using Umbraco.Cms.Core.Models.ContentPublishing;
57
using Umbraco.Cms.Core.PropertyEditors.Validation;
68
using Umbraco.Cms.Core.Services.OperationStatus;
79
using Umbraco.Extensions;
@@ -10,6 +12,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content;
1012

1113
public abstract class ContentControllerBase : ManagementApiControllerBase
1214
{
15+
protected abstract string EntityName { get; }
16+
1317
protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status)
1418
=> OperationStatusResult(status, problemDetailsBuilder => status switch
1519
{
@@ -78,12 +82,12 @@ protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperat
7882
.WithDetail("The supplied name is already in use for the same content type.")
7983
.Build()),
8084
ContentEditingOperationStatus.CannotDeleteWhenReferenced => BadRequest(problemDetailsBuilder
81-
.WithTitle("Cannot delete a referenced content item")
82-
.WithDetail("Cannot delete a referenced document, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
85+
.WithTitle($"Cannot delete a referenced {EntityName}")
86+
.WithDetail($"Cannot delete a referenced {EntityName}, while the setting ContentSettings.DisableDeleteWhenReferenced is enabled.")
8387
.Build()),
8488
ContentEditingOperationStatus.CannotMoveToRecycleBinWhenReferenced => BadRequest(problemDetailsBuilder
85-
.WithTitle("Cannot move a referenced document to the recycle bin")
86-
.WithDetail("Cannot move a referenced document to the recycle bin, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
89+
.WithTitle($"Cannot move a referenced {EntityName} to the recycle bin")
90+
.WithDetail($"Cannot move a referenced {EntityName} to the recycle bin, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
8791
.Build()),
8892
ContentEditingOperationStatus.Unknown => StatusCode(
8993
StatusCodes.Status500InternalServerError,
@@ -106,6 +110,122 @@ protected IActionResult GetReferencesOperationStatusResult(GetReferencesOperatio
106110
.Build()),
107111
});
108112

113+
protected IActionResult ContentPublishingOperationStatusResult(
114+
ContentPublishingOperationStatus status,
115+
IEnumerable<string>? invalidPropertyAliases = null,
116+
IEnumerable<ContentPublishingBranchItemResult>? failedBranchItems = null)
117+
=> OperationStatusResult(
118+
status,
119+
problemDetailsBuilder => status switch
120+
{
121+
ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder
122+
.WithTitle($"The requested {EntityName} could not be found")
123+
.Build()),
124+
ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder
125+
.WithTitle("Publish cancelled by event")
126+
.WithDetail("The publish operation was cancelled by an event.")
127+
.Build()),
128+
ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder
129+
.WithTitle($"Invalid {EntityName}")
130+
.WithDetail($"The specified {EntityName} had an invalid configuration.")
131+
.WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty<string>())
132+
.Build()),
133+
ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder
134+
.WithTitle("Nothing to publish")
135+
.WithDetail("None of the specified cultures needed publishing.")
136+
.Build()),
137+
ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder
138+
.WithTitle("Mandatory culture missing")
139+
.WithDetail("Must include all mandatory cultures when publishing.")
140+
.Build()),
141+
ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder
142+
.WithTitle($"{EntityName.ToFirstUpperInvariant()} expired")
143+
.WithDetail($"Could not publish the {EntityName} because it was expired.")
144+
.Build()),
145+
ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder
146+
.WithTitle($"{EntityName.ToFirstUpperInvariant()} culture expired")
147+
.WithDetail($"Could not publish the {EntityName} because some of the specified cultures were expired.")
148+
.Build()),
149+
ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder
150+
.WithTitle($"{EntityName.ToFirstUpperInvariant()} awaiting release")
151+
.WithDetail($"Could not publish the {EntityName} because it was awaiting release.")
152+
.Build()),
153+
ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder
154+
.WithTitle($"{EntityName.ToFirstUpperInvariant()} culture awaiting release")
155+
.WithDetail(
156+
$"Could not publish the {EntityName} because some of the specified cultures were awaiting release.")
157+
.Build()),
158+
ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder
159+
.WithTitle($"{EntityName.ToFirstUpperInvariant()} in the recycle bin")
160+
.WithDetail($"Could not publish the {EntityName} because it was in the recycle bin.")
161+
.Build()),
162+
ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder
163+
.WithTitle("Parent not published")
164+
.WithDetail($"Could not publish the {EntityName} because its parent was not published.")
165+
.Build()),
166+
ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder
167+
.WithTitle("Invalid cultures specified")
168+
.WithDetail("A specified culture is not valid for the operation.")
169+
.Build()),
170+
ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder
171+
.WithTitle("Culture missing")
172+
.WithDetail("A culture needs to be specified to execute the operation.")
173+
.Build()),
174+
ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder
175+
.WithTitle("Cannot publish invariant when variant")
176+
.WithDetail($"Cannot publish invariant culture when the {EntityName} varies by culture.")
177+
.Build()),
178+
ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder
179+
.WithTitle("Cannot publish variant when not variant.")
180+
.WithDetail($"Cannot publish a given culture when the {EntityName} is invariant.")
181+
.Build()),
182+
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder
183+
.WithTitle("Concurrency violation detected")
184+
.WithDetail("An attempt was made to publish a version older than the latest version.")
185+
.Build()),
186+
ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder
187+
.WithTitle("Unsaved changes")
188+
.WithDetail(
189+
$"Could not publish the {EntityName} because it had unsaved changes. Make sure to save all changes before attempting a publish.")
190+
.Build()),
191+
ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder
192+
.WithTitle("Unpublish time needs to be after the publish time")
193+
.WithDetail(
194+
"Cannot handle an unpublish time that is not after the specified publish time.")
195+
.Build()),
196+
ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
197+
.WithTitle("Publish time needs to be higher than the current time")
198+
.WithDetail(
199+
"Cannot handle a publish time that is not after the current server time.")
200+
.Build()),
201+
ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
202+
.WithTitle("Unpublish time needs to be higher than the current time")
203+
.WithDetail(
204+
"Cannot handle an unpublish time that is not after the current server time.")
205+
.Build()),
206+
ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder
207+
.WithTitle($"Cannot unpublish {EntityName} when it's referenced somewhere else.")
208+
.WithDetail(
209+
$"Cannot unpublish a referenced {EntityName}, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
210+
.Build()),
211+
ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder
212+
.WithTitle("Failed branch operation")
213+
.WithDetail("One or more items in the branch could not complete the operation.")
214+
.WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult { Id = item.Key, OperationStatus = item.OperationStatus }) ?? [])
215+
.Build()),
216+
ContentPublishingOperationStatus.Failed => BadRequest(
217+
problemDetailsBuilder
218+
.WithTitle("Publish or unpublish failed")
219+
.WithDetail(
220+
"An unspecified error occurred while (un)publishing. Please check the logs for additional information.")
221+
.Build()),
222+
ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder
223+
.WithTitle("The result of the submitted task could not be found")
224+
.Build()),
225+
226+
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."),
227+
});
228+
109229
protected IActionResult ContentEditingOperationStatusResult<TContentModelBase, TValueModel, TVariantModel>(
110230
ContentEditingOperationStatus status,
111231
TContentModelBase requestModel,

src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs

Lines changed: 3 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
1717
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
1818
public abstract class DocumentControllerBase : ContentControllerBase
1919
{
20+
protected override string EntityName => "document";
21+
2022
protected IActionResult DocumentNotFound()
2123
=> OperationStatusResult(ContentEditingOperationStatus.NotFound, problemDetailsBuilder
2224
=> NotFound(problemDetailsBuilder
@@ -30,123 +32,11 @@ protected IActionResult DocumentEditingOperationStatusResult<TContentModelBase>(
3032
where TContentModelBase : ContentModelBase<DocumentValueModel, DocumentVariantRequestModel>
3133
=> ContentEditingOperationStatusResult<TContentModelBase, DocumentValueModel, DocumentVariantRequestModel>(status, requestModel, validationResult);
3234

33-
// TODO ELEMENTS: move this to ContentControllerBase
3435
protected IActionResult DocumentPublishingOperationStatusResult(
3536
ContentPublishingOperationStatus status,
3637
IEnumerable<string>? invalidPropertyAliases = null,
3738
IEnumerable<ContentPublishingBranchItemResult>? failedBranchItems = null)
38-
=> OperationStatusResult(status, problemDetailsBuilder => status switch
39-
{
40-
ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder
41-
.WithTitle("The requested document could not be found")
42-
.Build()),
43-
ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder
44-
.WithTitle("Publish cancelled by event")
45-
.WithDetail("The publish operation was cancelled by an event.")
46-
.Build()),
47-
ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder
48-
.WithTitle("Invalid document")
49-
.WithDetail("The specified document had an invalid configuration.")
50-
.WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty<string>())
51-
.Build()),
52-
ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder
53-
.WithTitle("Nothing to publish")
54-
.WithDetail("None of the specified cultures needed publishing.")
55-
.Build()),
56-
ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder
57-
.WithTitle("Mandatory culture missing")
58-
.WithDetail("Must include all mandatory cultures when publishing.")
59-
.Build()),
60-
ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder
61-
.WithTitle("Document expired")
62-
.WithDetail("Could not publish the document because it was expired.")
63-
.Build()),
64-
ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder
65-
.WithTitle("Document culture expired")
66-
.WithDetail("Could not publish the document because some of the specified cultures were expired.")
67-
.Build()),
68-
ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder
69-
.WithTitle("Document awaiting release")
70-
.WithDetail("Could not publish the document because it was awaiting release.")
71-
.Build()),
72-
ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder
73-
.WithTitle("Document culture awaiting release")
74-
.WithDetail(
75-
"Could not publish the document because some of the specified cultures were awaiting release.")
76-
.Build()),
77-
ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder
78-
.WithTitle("Document in the recycle bin")
79-
.WithDetail("Could not publish the document because it was in the recycle bin.")
80-
.Build()),
81-
ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder
82-
.WithTitle("Parent not published")
83-
.WithDetail("Could not publish the document because its parent was not published.")
84-
.Build()),
85-
ContentPublishingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder
86-
.WithTitle("Invalid cultures specified")
87-
.WithDetail("A specified culture is not valid for the operation.")
88-
.Build()),
89-
ContentPublishingOperationStatus.CultureMissing => BadRequest(problemDetailsBuilder
90-
.WithTitle("Culture missing")
91-
.WithDetail("A culture needs to be specified to execute the operation.")
92-
.Build()),
93-
ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant => BadRequest(problemDetailsBuilder
94-
.WithTitle("Cannot publish invariant when variant")
95-
.WithDetail("Cannot publish invariant culture when the document varies by culture.")
96-
.Build()),
97-
ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant => BadRequest(problemDetailsBuilder
98-
.WithTitle("Cannot publish variant when not variant.")
99-
.WithDetail("Cannot publish a given culture when the document is invariant.")
100-
.Build()),
101-
ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder
102-
.WithTitle("Concurrency violation detected")
103-
.WithDetail("An attempt was made to publish a version older than the latest version.")
104-
.Build()),
105-
ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder
106-
.WithTitle("Unsaved changes")
107-
.WithDetail(
108-
"Could not publish the document because it had unsaved changes. Make sure to save all changes before attempting a publish.")
109-
.Build()),
110-
ContentPublishingOperationStatus.UnpublishTimeNeedsToBeAfterPublishTime => BadRequest(problemDetailsBuilder
111-
.WithTitle("Unpublish time needs to be after the publish time")
112-
.WithDetail(
113-
"Cannot handle an unpublish time that is not after the specified publish time.")
114-
.Build()),
115-
ContentPublishingOperationStatus.PublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
116-
.WithTitle("Publish time needs to be higher than the current time")
117-
.WithDetail(
118-
"Cannot handle a publish time that is not after the current server time.")
119-
.Build()),
120-
ContentPublishingOperationStatus.UpublishTimeNeedsToBeInFuture => BadRequest(problemDetailsBuilder
121-
.WithTitle("Unpublish time needs to be higher than the current time")
122-
.WithDetail(
123-
"Cannot handle an unpublish time that is not after the current server time.")
124-
.Build()),
125-
ContentPublishingOperationStatus.CannotUnpublishWhenReferenced => BadRequest(problemDetailsBuilder
126-
.WithTitle("Cannot unpublish document when it's referenced somewhere else.")
127-
.WithDetail(
128-
"Cannot unpublish a referenced document, while the setting ContentSettings.DisableUnpublishWhenReferenced is enabled.")
129-
.Build()),
130-
ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder
131-
.WithTitle("Failed branch operation")
132-
.WithDetail("One or more items in the branch could not complete the operation.")
133-
.WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult
134-
{
135-
Id = item.Key,
136-
OperationStatus = item.OperationStatus
137-
}) ?? Enumerable.Empty<DocumentPublishBranchItemResult>())
138-
.Build()),
139-
ContentPublishingOperationStatus.Failed => BadRequest(problemDetailsBuilder
140-
.WithTitle("Publish or unpublish failed")
141-
.WithDetail(
142-
"An unspecified error occurred while (un)publishing. Please check the logs for additional information.")
143-
.Build()),
144-
ContentPublishingOperationStatus.TaskResultNotFound => NotFound(problemDetailsBuilder
145-
.WithTitle("The result of the submitted task could not be found")
146-
.Build()),
147-
148-
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."),
149-
});
39+
=> ContentPublishingOperationStatusResult(status, invalidPropertyAliases, failedBranchItems);
15040

15141
protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status)
15242
=> OperationStatusResult(status, problemDetailsBuilder => status switch

0 commit comments

Comments
 (0)