diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs index 21de9519ea..4d14381ca6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/DataTypeControllerBase.cs @@ -46,6 +46,10 @@ public abstract class DataTypeControllerBase : ManagementApiControllerBase DataTypeOperationStatus.ParentNotFound => NotFound(new ProblemDetailsBuilder() .WithTitle("The targeted parent for the data type operation was not found.") .Build()), + DataTypeOperationStatus.NonDeletable => BadRequest(new ProblemDetailsBuilder() + .WithTitle("The data type is non-deletable") + .WithDetail("The specified data type is required by the system and cannot be deleted.") + .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() .WithTitle("Unknown data type operation status.") .Build()), diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/DataTypeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/DataTypeTreeControllerBase.cs index f2628133ac..d89881fed1 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/DataTypeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/DataTypeTreeControllerBase.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Routing; using Umbraco.Cms.Api.Management.ViewModels.DataType.Item; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree; @@ -39,6 +40,7 @@ public class DataTypeTreeControllerBase : FolderTreeControllerBase configuration = configurationEditor?.ToConfigurationEditor(source.ConfigurationData) diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs index 78dd870217..d52bc1dae2 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Item/ItemTypeMapDefinition.cs @@ -14,6 +14,7 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Mapping.Item; @@ -47,6 +48,7 @@ public class ItemTypeMapDefinition : IMapDefinition target.Name = source.Name ?? string.Empty; target.Id = source.Key; target.EditorUiAlias = source.EditorUiAlias; + target.IsDeletable = source.IsDeletableDataType(); } // Umbraco.Code.MapAll diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 80a27afb2b..2477d7fe6d 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -26313,6 +26313,9 @@ "type": "string" }, "DataTypeItemResponseModel": { + "required": [ + "isDeletable" + ], "type": "object", "allOf": [ { @@ -26323,6 +26326,9 @@ "editorUiAlias": { "type": "string", "nullable": true + }, + "isDeletable": { + "type": "boolean" } }, "additionalProperties": false @@ -26421,7 +26427,8 @@ }, "DataTypeResponseModel": { "required": [ - "id" + "id", + "isDeletable" ], "type": "object", "allOf": [ @@ -26441,11 +26448,17 @@ } ], "nullable": true + }, + "isDeletable": { + "type": "boolean" } }, "additionalProperties": false }, "DataTypeTreeItemResponseModel": { + "required": [ + "isDeletable" + ], "type": "object", "allOf": [ { @@ -26456,6 +26469,9 @@ "editorUiAlias": { "type": "string", "nullable": true + }, + "isDeletable": { + "type": "boolean" } }, "additionalProperties": false diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeResponseModel.cs index 8c7a9a6c6a..e147db6197 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeResponseModel.cs @@ -5,4 +5,6 @@ public class DataTypeResponseModel : DataTypeModelBase public Guid Id { get; set; } public ReferenceByIdModel? Parent { get; set; } + + public bool IsDeletable { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeItemResponseModel.cs index 0381396904..b753aa22b2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeItemResponseModel.cs @@ -5,4 +5,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.DataType.Item; public class DataTypeItemResponseModel : NamedItemResponseModelBase { public string? EditorUiAlias { get; set; } + + public bool IsDeletable { get; set; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeTreeItemResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeTreeItemResponseModel.cs index 5717d2a7e0..8cbf251135 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeTreeItemResponseModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/Item/DataTypeTreeItemResponseModel.cs @@ -5,4 +5,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.DataType.Item; public class DataTypeTreeItemResponseModel : FolderTreeItemResponseModel { public string? EditorUiAlias { get; set; } + + public bool IsDeletable { get; set; } } diff --git a/src/Umbraco.Core/Models/DataTypeExtensions.cs b/src/Umbraco.Core/Models/DataTypeExtensions.cs index 22b5aba126..5dd314f241 100644 --- a/src/Umbraco.Core/Models/DataTypeExtensions.cs +++ b/src/Umbraco.Core/Models/DataTypeExtensions.cs @@ -42,10 +42,25 @@ public static class DataTypeExtensions Constants.DataTypes.Guids.LabelDecimalGuid, Constants.DataTypes.Guids.LabelDateTimeGuid, Constants.DataTypes.Guids.LabelBigIntGuid, + Constants.DataTypes.Guids.LabelIntGuid, Constants.DataTypes.Guids.LabelTimeGuid, Constants.DataTypes.Guids.LabelDateTimeGuid, }; + private static readonly ISet IdsOfNonDeletableDataTypes = new HashSet + { + // these data types are required by PublishedContentType when creating system properties for members + // (e.g. username, last login date, approval status) + Constants.DataTypes.Guids.CheckboxGuid, + Constants.DataTypes.Guids.TextstringGuid, + Constants.DataTypes.Guids.LabelDateTimeGuid, + + // these data types are required for default list view handling + Constants.DataTypes.Guids.ListViewContentGuid, + Constants.DataTypes.Guids.ListViewMediaGuid, + Constants.DataTypes.Guids.ListViewMembersGuid, + }; + /// /// Gets the configuration object. /// @@ -75,14 +90,26 @@ public static class DataTypeExtensions } /// - /// Returns true if this date type is build-in/default. + /// Returns true if this data type is build-in/default. /// /// The data type definition. /// public static bool IsBuildInDataType(this IDataType dataType) => IsBuildInDataType(dataType.Key); /// - /// Returns true if this date type is build-in/default. + /// Returns true if this data type is build-in/default. /// public static bool IsBuildInDataType(Guid key) => IdsOfBuildInDataTypes.Contains(key); + + /// + /// Returns true if this data type can be deleted. + /// + /// The data type definition. + /// + public static bool IsDeletableDataType(this IDataType dataType) => IsDeletableDataType(dataType.Key); + + /// + /// Returns true if this data type can be deleted. + /// + public static bool IsDeletableDataType(Guid key) => IdsOfNonDeletableDataTypes.Contains(key) is false; } diff --git a/src/Umbraco.Core/Services/DataTypeService.cs b/src/Umbraco.Core/Services/DataTypeService.cs index 733638fd95..fcffe4734e 100644 --- a/src/Umbraco.Core/Services/DataTypeService.cs +++ b/src/Umbraco.Core/Services/DataTypeService.cs @@ -590,6 +590,12 @@ namespace Umbraco.Cms.Core.Services.Implement return Attempt.FailWithStatus(DataTypeOperationStatus.NotFound, dataType); } + if (dataType.IsDeletableDataType() is false) + { + scope.Complete(); + return Attempt.FailWithStatus(DataTypeOperationStatus.NonDeletable, dataType); + } + var deletingDataTypeNotification = new DataTypeDeletingNotification(dataType, eventMessages); if (await scope.Notifications.PublishCancelableAsync(deletingDataTypeNotification)) { diff --git a/src/Umbraco.Core/Services/OperationStatus/DataTypeOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/DataTypeOperationStatus.cs index a68f0c91d4..34178a85c4 100644 --- a/src/Umbraco.Core/Services/OperationStatus/DataTypeOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/DataTypeOperationStatus.cs @@ -11,5 +11,6 @@ public enum DataTypeOperationStatus NotFound, ParentNotFound, ParentNotContainer, - PropertyEditorNotFound + PropertyEditorNotFound, + NonDeletable } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs index bb916cf41c..0c9d514ef6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DataTypeServiceTests.cs @@ -139,16 +139,21 @@ public class DataTypeServiceTests : UmbracoIntegrationTest } [Test] - public async Task DataTypeService_Can_Delete_Textfield_DataType_And_Clear_Usages() + public async Task DataTypeService_Can_Delete_DataType_And_Clear_Usages() { // Arrange - var dataTypeDefinitions = await DataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.TextBox); + var dataTypeDefinitions = await DataTypeService.GetByEditorAliasAsync(Constants.PropertyEditors.Aliases.RichText); var template = TemplateBuilder.CreateTextPageTemplate(); FileService.SaveTemplate(template); var doctype = ContentTypeBuilder.CreateSimpleContentType("umbTextpage", "Textpage", defaultTemplateId: template.Id); ContentTypeService.Save(doctype); + // validate the assumptions used for assertions later in this test + var contentType = ContentTypeService.Get(doctype.Id); + Assert.AreEqual(3, contentType.PropertyTypes.Count()); + Assert.IsNotNull(contentType.PropertyTypes.SingleOrDefault(pt => pt.PropertyEditorAlias is Constants.PropertyEditors.Aliases.RichText)); + // Act var definition = dataTypeDefinitions.First(); var definitionKey = definition.Key; @@ -164,9 +169,9 @@ public class DataTypeServiceTests : UmbracoIntegrationTest Assert.That(deletedDefinition, Is.Null); // Further assertions against the ContentType that contains PropertyTypes based on the TextField - var contentType = ContentTypeService.Get(doctype.Id); + contentType = ContentTypeService.Get(doctype.Id); Assert.That(contentType.Alias, Is.EqualTo("umbTextpage")); - Assert.That(contentType.PropertyTypes.Count(), Is.EqualTo(1)); + Assert.That(contentType.PropertyTypes.Count(), Is.EqualTo(2)); } [Test] @@ -426,4 +431,20 @@ public class DataTypeServiceTests : UmbracoIntegrationTest Assert.IsNotNull(dataType); Assert.AreEqual(Constants.System.Root, dataType.ParentId); } + + [TestCase(Constants.DataTypes.Guids.LabelDateTime)] + [TestCase(Constants.DataTypes.Guids.Textstring)] + [TestCase(Constants.DataTypes.Guids.Checkbox)] + [TestCase(Constants.DataTypes.Guids.ListViewContent)] + [TestCase(Constants.DataTypes.Guids.ListViewMedia)] + [TestCase(Constants.DataTypes.Guids.ListViewMembers)] + public async Task Cannot_Delete_NonDeletable_DataType(string dataTypeKey) + { + var dataType = await DataTypeService.GetAsync(Guid.Parse(dataTypeKey)); + Assert.IsNotNull(dataType); + + var result = await DataTypeService.DeleteAsync(dataType.Key, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(DataTypeOperationStatus.NonDeletable, result.Status); + } }