From ec1ba6031ff96f3cc64b383fbaf09607c220ae1c Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Wed, 14 Feb 2024 15:13:56 +0100 Subject: [PATCH] V14: Document and media collection view endpoints (#15696) * Fix authz status * Adding another silo for list views * Adding base classes and handling collection operation statuses * Change signature to reuse functionality. Fix references * Adding collection response models * Adding content and media type collection response models * Adding mapping * Adding mapping for document and media types * Adding list view page model * Initial implementation * Moving implementation to service base * Adding content and media service interfaces for handling list views * Registering and implementation * Update controllers to use new services * Renaming param * Refactor to pass content type instead of content type key * Handle the case where only data type is provided * Add missing operation status * Update OpenApi.json * Added comment for a temp workaround * Removing orderCulture from media interface as it is not yet supported * Adding a base class for content type collection reference model * Adding common collection controller base and moving the ContentCollectionOperationStatusResult to there * Cleaning up controllers after implementing the base class * Cleaning up concrete controller bases * OpenApi.json updates * Changing GetPagedChildren to return a paged model * Fix ordering * Adding , * Fix wording * Append operation status to unsuccessful API responses * A little bit of clean-up * Update default orderBy value * Update the default value of orderBy * Adding missing owner and updater system fields * Updating OpenApi.json with owner and updater props * Create base and rename owner to creator * Update OpenApi.json * Reordering of properties * "Owner" will be "creator" * Fix comment --------- Co-authored-by: kjac --- .../ContentCollectionControllerBase.cs | 98 +++ .../ByKeyDocumentCollectionController.cs | 61 ++ .../DocumentCollectionControllerBase.cs | 28 + .../ByKeyMediaCollectionController.cs | 59 ++ .../MediaCollectionControllerBase.cs | 28 + .../Mapping/Content/ContentMapDefinition.cs | 5 +- .../Mapping/Document/DocumentMapDefinition.cs | 47 +- .../DocumentType/DocumentTypeMapDefinition.cs | 9 + .../Mapping/Media/MediaMapDefinition.cs | 38 +- .../MediaType/MediaTypeMapDefinition.cs | 9 + .../Mapping/Member/MemberMapDefinition.cs | 2 +- src/Umbraco.Cms.Api.Management/OpenApi.json | 649 ++++++++++++++++++ .../ContentCollectionResponseModelBase.cs | 11 + ...ypeCollectionReferenceResponseModelBase.cs | 10 + .../DocumentCollectionResponseModel.cs | 11 + ...entTypeCollectionReferenceResponseModel.cs | 7 + .../MediaCollectionResponseModel.cs | 9 + ...diaTypeCollectionReferenceResponseModel.cs | 7 + src/Umbraco.Core/Constants-Web.cs | 1 + src/Umbraco.Core/Models/ListViewPagedModel.cs | 11 + .../ContentAuthorizationStatus.cs | 3 +- .../Services/ContentPermissionService.cs | 6 +- .../Services/IContentListViewService.cs | 19 + .../Services/IMediaListViewService.cs | 18 + .../ContentCollectionOperationStatus.cs | 17 + .../UmbracoBuilder.Services.cs | 2 + .../Services/ContentListViewServiceBase.cs | 287 ++++++++ .../Implement/ContentListViewService.cs | 98 +++ .../Implement/MediaListViewService.cs | 92 +++ 29 files changed, 1628 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeCollectionReferenceResponseModelBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/DocumentType/DocumentTypeCollectionReferenceResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/MediaType/MediaTypeCollectionReferenceResponseModel.cs create mode 100644 src/Umbraco.Core/Models/ListViewPagedModel.cs create mode 100644 src/Umbraco.Core/Services/IContentListViewService.cs create mode 100644 src/Umbraco.Core/Services/IMediaListViewService.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/ContentCollectionOperationStatus.cs create mode 100644 src/Umbraco.Infrastructure/Services/ContentListViewServiceBase.cs create mode 100644 src/Umbraco.Infrastructure/Services/Implement/ContentListViewService.cs create mode 100644 src/Umbraco.Infrastructure/Services/Implement/MediaListViewService.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs new file mode 100644 index 0000000000..973a9d4a2e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentCollectionControllerBase.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Content; + +public abstract class ContentCollectionControllerBase : ManagementApiControllerBase + where TContent : class, IContentBase + where TCollectionResponseModel : ContentResponseModelBase + where TValueResponseModelBase : ValueModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + private readonly IUmbracoMapper _mapper; + + protected ContentCollectionControllerBase(IUmbracoMapper mapper) => _mapper = mapper; + + protected IActionResult CollectionResult(ListViewPagedModel result) + { + PagedModel collectionItemsResult = result.Items; + ListViewConfiguration collectionConfiguration = result.ListViewConfiguration; + + var collectionPropertyAliases = collectionConfiguration + .IncludeProperties + .Select(p => p.Alias) + .WhereNotNull() + .ToArray(); + + List collectionResponseModels = + _mapper.MapEnumerable(collectionItemsResult.Items, context => + { + context.SetIncludedProperties(collectionPropertyAliases); + }); + + var pageViewModel = new PagedViewModel + { + Items = collectionResponseModels, + Total = collectionItemsResult.Total, + }; + + return Ok(pageViewModel); + } + + protected IActionResult ContentCollectionOperationStatusResult(ContentCollectionOperationStatus status, string type) => + OperationStatusResult(status, problemDetailsBuilder => status switch + { + ContentCollectionOperationStatus.CollectionNotFound => new NotFoundObjectResult(problemDetailsBuilder + .WithTitle("Collection data type could not be found") + .WithDetail($"No collection data type was found for the corresponding {type} type. Ensure that the default and/or a custom collection data types exists") + .Build()), + ContentCollectionOperationStatus.ContentNotCollection => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle($"The {type} item is not configured as a collection") + .WithDetail($"The specified {type} is not configured as a collection") + .Build()), + ContentCollectionOperationStatus.ContentNotFound => new NotFoundObjectResult(problemDetailsBuilder + .WithTitle($"The specified {type} could not be found") + .Build()), + ContentCollectionOperationStatus.ContentTypeNotFound => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle($"The related {type} type could not be found") + .Build()), + ContentCollectionOperationStatus.DataTypeNotCollection => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle("Data type id does not belong to a collection") + .WithDetail("The specified data type does not represent a collection") + .Build()), + ContentCollectionOperationStatus.DataTypeNotContentCollection => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle("Data type id does not represent the configured collection") + .WithDetail($"The specified data type is not the configured collection for the given {type} item") + .Build()), + ContentCollectionOperationStatus.DataTypeNotContentProperty => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle($"Data type id is not a {type} property") + .WithDetail($"The specified data type is not part of the {type} properties") + .Build()), + ContentCollectionOperationStatus.DataTypeNotFound => new NotFoundObjectResult(problemDetailsBuilder + .WithTitle("The specified collection data type could not be found") + .Build()), + ContentCollectionOperationStatus.DataTypeWithoutContentType => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle($"Missing {type} when specifying a collection data type") + .WithDetail($"The specified collection data type needs to be used in conjunction with a {type} item.") + .Build()), + ContentCollectionOperationStatus.MissingPropertiesInCollectionConfiguration => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle("Missing properties in collection configuration") + .WithDetail("No properties are configured to display in the collection configuration") + .Build()), + ContentCollectionOperationStatus.OrderByNotPartOfCollectionConfiguration => new BadRequestObjectResult(problemDetailsBuilder + .WithTitle("Order by value is not a property on the configured collection") + .WithDetail("The specified orderBy property is not part of the collection configuration") + .Build()), + _ => new ObjectResult("Unknown content collection operation status") + { + StatusCode = StatusCodes.Status500InternalServerError, + }, + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs new file mode 100644 index 0000000000..8fbc432b09 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/ByKeyDocumentCollectionController.cs @@ -0,0 +1,61 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +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.Document.Collection; + +[ApiVersion("1.0")] +public class ByKeyDocumentCollectionController : DocumentCollectionControllerBase +{ + private readonly IContentListViewService _contentListViewService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ByKeyDocumentCollectionController( + IContentListViewService contentListViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper) + : base(mapper) + { + _contentListViewService = contentListViewService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpGet("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ByKey( + Guid id, + Guid? dataTypeId = null, + string orderBy = "updateDate", + string? orderCulture = null, + Direction orderDirection = Direction.Ascending, + string? filter = null, + int skip = 0, + int take = 100) + { + Attempt?, ContentCollectionOperationStatus> collectionAttempt = await _contentListViewService.GetListViewItemsByKeyAsync( + CurrentUser(_backOfficeSecurityAccessor), + id, + dataTypeId, + orderBy, + orderCulture, + orderDirection, + filter, + skip, + take); + + return collectionAttempt.Success + ? CollectionResult(collectionAttempt.Result!) + : CollectionOperationStatusResult(collectionAttempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs new file mode 100644 index 0000000000..20b1553d8d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Collection/DocumentCollectionControllerBase.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Content; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Document.Collection; + +[ApiController] +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Collection}/{Constants.UdiEntityType.Document}")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Document))] +[Authorize(Policy = "New" + AuthorizationPolicies.TreeAccessDocuments)] +public abstract class DocumentCollectionControllerBase : ContentCollectionControllerBase +{ + protected DocumentCollectionControllerBase(IUmbracoMapper mapper) + : base(mapper) + { + } + + protected IActionResult CollectionOperationStatusResult(ContentCollectionOperationStatus status) + => ContentCollectionOperationStatusResult(status, "document"); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs new file mode 100644 index 0000000000..7a2ed07ee8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/ByKeyMediaCollectionController.cs @@ -0,0 +1,59 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +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.Media.Collection; + +[ApiVersion("1.0")] +public class ByKeyMediaCollectionController : MediaCollectionControllerBase +{ + private readonly IMediaListViewService _mediaListViewService; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public ByKeyMediaCollectionController( + IMediaListViewService mediaListViewService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUmbracoMapper mapper) + : base(mapper) + { + _mediaListViewService = mediaListViewService; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ByKey( + Guid? id, + Guid? dataTypeId = null, + string orderBy = "updateDate", + Direction orderDirection = Direction.Ascending, + string? filter = null, + int skip = 0, + int take = 100) + { + Attempt?, ContentCollectionOperationStatus> collectionAttempt = await _mediaListViewService.GetListViewItemsByKeyAsync( + CurrentUser(_backOfficeSecurityAccessor), + id, + dataTypeId, + orderBy, + orderDirection, + filter, + skip, + take); + + return collectionAttempt.Success + ? CollectionResult(collectionAttempt.Result!) + : CollectionOperationStatusResult(collectionAttempt.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs new file mode 100644 index 0000000000..f96ea545ed --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Collection/MediaCollectionControllerBase.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Controllers.Content; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Api.Management.ViewModels.Media.Collection; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.Controllers.Media.Collection; + +[ApiController] +[VersionedApiBackOfficeRoute($"{Constants.Web.RoutePath.Collection}/{Constants.UdiEntityType.Media}")] +[ApiExplorerSettings(GroupName = nameof(Constants.UdiEntityType.Media))] +[Authorize(Policy = "New" + AuthorizationPolicies.SectionAccessMedia)] +public abstract class MediaCollectionControllerBase : ContentCollectionControllerBase +{ + protected MediaCollectionControllerBase(IUmbracoMapper mapper) + : base(mapper) + { + } + + protected IActionResult CollectionOperationStatusResult(ContentCollectionOperationStatus status) + => ContentCollectionOperationStatusResult(status, "media"); +} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs index 55a58e0c02..fe8b3ebae6 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Content/ContentMapDefinition.cs @@ -18,9 +18,8 @@ public abstract class ContentMapDefinition MapValueViewModels(TContent source, ValueViewModelMapping? additionalPropertyMapping = null) => - source - .Properties + protected IEnumerable MapValueViewModels(IEnumerable properties, ValueViewModelMapping? additionalPropertyMapping = null) => + properties .SelectMany(property => property .Values .Select(propertyValue => diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs index ca91cfc4e4..46b5c519ef 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Document/DocumentMapDefinition.cs @@ -1,28 +1,37 @@ using Umbraco.Cms.Api.Management.Mapping.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document.Collection; using Umbraco.Cms.Api.Management.ViewModels.DocumentType; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Mapping; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Mapping.Document; public class DocumentMapDefinition : ContentMapDefinition, IMapDefinition { - public DocumentMapDefinition(PropertyEditorCollection propertyEditorCollection) + private readonly CommonMapper _commonMapper; + + public DocumentMapDefinition(PropertyEditorCollection propertyEditorCollection, CommonMapper commonMapper) : base(propertyEditorCollection) { + _commonMapper = commonMapper; } public void DefineMaps(IUmbracoMapper mapper) - => mapper.Define((_, _) => new DocumentResponseModel(), Map); + { + mapper.Define((_, _) => new DocumentResponseModel(), Map); + mapper.Define((_, _) => new DocumentCollectionResponseModel(), Map); + } // Umbraco.Code.MapAll -Urls -Template private void Map(IContent source, DocumentResponseModel target, MapperContext context) { target.Id = source.Key; target.DocumentType = context.Map(source.ContentType)!; - target.Values = MapValueViewModels(source); + target.Values = MapValueViewModels(source.Properties); target.Variants = MapVariantViewModels( source, (culture, _, documentVariantViewModel) => @@ -34,4 +43,36 @@ public class DocumentMapDefinition : ContentMapDefinition(source.ContentType)!; + target.SortOrder = source.SortOrder; + target.Creator = _commonMapper.GetOwner(source, context)?.Name; + target.Updater = _commonMapper.GetCreator(source, context)?.Name; + + // If there's a set of property aliases specified in the collection configuration, we will check if the current property's + // value should be mapped. If it isn't one of the ones specified in 'includeProperties', we will just return the result + // without mapping the value. + var includedProperties = context.GetIncludedProperties(); + + IEnumerable properties = source.Properties; + if (includedProperties is not null) + { + properties = properties.Where(property => includedProperties.Contains(property.Alias)); + } + + target.Values = MapValueViewModels(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/Mapping/DocumentType/DocumentTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs index a41e7a8578..8b81f5d7b8 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/DocumentType/DocumentTypeMapDefinition.cs @@ -15,6 +15,7 @@ public class DocumentTypeMapDefinition : ContentTypeMapDefinition((_, _) => new DocumentTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new DocumentTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new AllowedDocumentType(), Map); + mapper.Define((_, _) => new DocumentTypeCollectionReferenceResponseModel(), Map); } // Umbraco.Code.MapAll @@ -82,4 +83,12 @@ public class DocumentTypeMapDefinition : ContentTypeMapDefinition, IMapDefinition { - public MediaMapDefinition(PropertyEditorCollection propertyEditorCollection) + private readonly CommonMapper _commonMapper; + + public MediaMapDefinition(PropertyEditorCollection propertyEditorCollection, CommonMapper commonMapper) : base(propertyEditorCollection) { + _commonMapper = commonMapper; } public void DefineMaps(IUmbracoMapper mapper) - => mapper.Define((_, _) => new MediaResponseModel(), Map); + { + mapper.Define((_, _) => new MediaResponseModel(), Map); + mapper.Define((_, _) => new MediaCollectionResponseModel(), Map); + } // Umbraco.Code.MapAll -Urls private void Map(IMedia source, MediaResponseModel target, MapperContext context) { target.Id = source.Key; target.MediaType = context.Map(source.ContentType)!; - target.Values = MapValueViewModels(source); + target.Values = MapValueViewModels(source.Properties); target.Variants = MapVariantViewModels(source); target.IsTrashed = source.Trashed; } + + // Umbraco.Code.MapAll + private void Map(IMedia source, MediaCollectionResponseModel target, MapperContext context) + { + target.Id = source.Key; + target.MediaType = context.Map(source.ContentType)!; + target.SortOrder = source.SortOrder; + target.Creator = _commonMapper.GetOwner(source, context)?.Name; + + // If there's a set of property aliases specified in the collection configuration, we will check if the current property's + // value should be mapped. If it isn't one of the ones specified in 'includeProperties', we will just return the result + // without mapping the value. + var includedProperties = context.GetIncludedProperties(); + + IEnumerable properties = source.Properties; + if (includedProperties is not null) + { + properties = properties.Where(property => includedProperties.Contains(property.Alias)); + } + + target.Values = MapValueViewModels(properties); + target.Variants = MapVariantViewModels(source); + } } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs index bc67838abc..6d1f213066 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/MediaType/MediaTypeMapDefinition.cs @@ -15,6 +15,7 @@ public class MediaTypeMapDefinition : ContentTypeMapDefinition((_, _) => new MediaTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new MediaTypeReferenceResponseModel(), Map); mapper.Define((_, _) => new AllowedMediaType(), Map); + mapper.Define((_, _) => new MediaTypeCollectionReferenceResponseModel(), Map); } // Umbraco.Code.MapAll @@ -65,4 +66,12 @@ public class MediaTypeMapDefinition : ContentTypeMapDefinition(source.ContentType)!; - target.Values = MapValueViewModels(source); + target.Values = MapValueViewModels(source.Properties); target.Variants = MapVariantViewModels(source); target.IsApproved = source.IsApproved; diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 10007bb0ce..336300b690 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -4276,6 +4276,150 @@ ] } }, + "/umbraco/management/api/v1/collection/document/{id}": { + "get": { + "tags": [ + "Document" + ], + "operationId": "GetCollectionDocumentById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "orderBy", + "in": "query", + "schema": { + "type": "string", + "default": "updateDate" + } + }, + { + "name": "orderCulture", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentCollectionResponseModel" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedDocumentCollectionResponseModel" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/document": { "post": { "tags": [ @@ -10271,6 +10415,142 @@ ] } }, + "/umbraco/management/api/v1/collection/media": { + "get": { + "tags": [ + "Media" + ], + "operationId": "GetCollectionMedia", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "orderBy", + "in": "query", + "schema": { + "type": "string", + "default": "updateDate" + } + }, + { + "name": "orderDirection", + "in": "query", + "schema": { + "$ref": "#/components/schemas/DirectionModel" + } + }, + { + "name": "filter", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedMediaCollectionResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PagedMediaCollectionResponseModel" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PagedMediaCollectionResponseModel" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/item/media": { "get": { "tags": [ @@ -10539,6 +10819,79 @@ } ] }, + "delete": { + "tags": [ + "Media" + ], + "operationId": "DeleteMediaById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + }, "put": { "tags": [ "Media" @@ -11195,6 +11548,81 @@ ] } }, + "/umbraco/management/api/v1/recycle-bin/media/{id}": { + "delete": { + "tags": [ + "Media" + ], + "operationId": "DeleteRecycleBinMediaById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource" + } + }, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, "/umbraco/management/api/v1/recycle-bin/media/children": { "get": { "tags": [ @@ -24001,6 +24429,94 @@ }, "additionalProperties": false }, + "ContentCollectionResponseModelBaseDocumentValueModelDocumentVariantResponseModel": { + "required": [ + "id", + "sortOrder", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantResponseModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "creator": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, + "ContentCollectionResponseModelBaseMediaValueModelMediaVariantResponseModel": { + "required": [ + "id", + "sortOrder", + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaVariantResponseModel" + } + ] + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "creator": { + "type": "string", + "nullable": true + }, + "sortOrder": { + "type": "integer", + "format": "int32" + } + }, + "additionalProperties": false + }, "ContentForDocumentResponseModel": { "required": [ "id", @@ -24165,6 +24681,27 @@ }, "additionalProperties": false }, + "ContentTypeCollectionReferenceResponseModelBaseModel": { + "required": [ + "alias", + "icon", + "id" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "alias": { + "type": "string" + }, + "icon": { + "type": "string" + } + }, + "additionalProperties": false + }, "ContentTypeCompositionRequestModelBaseModel": { "required": [ "currentCompositeIds", @@ -25968,6 +26505,31 @@ }, "additionalProperties": false }, + "DocumentCollectionResponseModel": { + "required": [ + "documentType" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ContentCollectionResponseModelBaseDocumentValueModelDocumentVariantResponseModel" + } + ], + "properties": { + "documentType": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeCollectionReferenceResponseModel" + } + ] + }, + "updater": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "DocumentConfigurationResponseModel": { "required": [ "allowEditInvariantFromNonDefault", @@ -26169,6 +26731,15 @@ ], "additionalProperties": false }, + "DocumentTypeCollectionReferenceResponseModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ContentTypeCollectionReferenceResponseModelBaseModel" + } + ], + "additionalProperties": false + }, "DocumentTypeCompositionModel": { "required": [ "compositionType", @@ -27610,6 +28181,27 @@ }, "additionalProperties": false }, + "MediaCollectionResponseModel": { + "required": [ + "mediaType" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ContentCollectionResponseModelBaseMediaValueModelMediaVariantResponseModel" + } + ], + "properties": { + "mediaType": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeCollectionReferenceResponseModel" + } + ] + } + }, + "additionalProperties": false + }, "MediaConfigurationResponseModel": { "required": [ "disableDeleteWhenReferenced", @@ -27766,6 +28358,15 @@ }, "additionalProperties": false }, + "MediaTypeCollectionReferenceResponseModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ContentTypeCollectionReferenceResponseModelBaseModel" + } + ], + "additionalProperties": false + }, "MediaTypeCompositionModel": { "required": [ "compositionType", @@ -28784,6 +29385,30 @@ }, "additionalProperties": false }, + "PagedDocumentCollectionResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentCollectionResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedDocumentRecycleBinItemResponseModel": { "required": [ "items", @@ -29048,6 +29673,30 @@ }, "additionalProperties": false }, + "PagedMediaCollectionResponseModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaCollectionResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "PagedMediaRecycleBinItemResponseModel": { "required": [ "items", diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs new file mode 100644 index 0000000000..231ed5edbd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentCollectionResponseModelBase.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Content; + +public abstract class ContentCollectionResponseModelBase + : ContentResponseModelBase + where TValueResponseModelBase : ValueModelBase + where TVariantResponseModel : VariantResponseModelBase +{ + public string? Creator { get; set; } + + public int SortOrder { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeCollectionReferenceResponseModelBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeCollectionReferenceResponseModelBase.cs new file mode 100644 index 0000000000..70d3b992ad --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/ContentType/ContentTypeCollectionReferenceResponseModelBase.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.ContentType; + +public abstract class ContentTypeCollectionReferenceResponseModelBase +{ + public Guid Id { get; set; } + + public string Alias { get; set; } = string.Empty; + + public string Icon { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs new file mode 100644 index 0000000000..453f2ee72a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/Collection/DocumentCollectionResponseModel.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.DocumentType; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document.Collection; + +public class DocumentCollectionResponseModel : ContentCollectionResponseModelBase +{ + public DocumentTypeCollectionReferenceResponseModel DocumentType { get; set; } = new(); + + public string? Updater { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DocumentType/DocumentTypeCollectionReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DocumentType/DocumentTypeCollectionReferenceResponseModel.cs new file mode 100644 index 0000000000..e20e987cfe --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DocumentType/DocumentTypeCollectionReferenceResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.ContentType; + +namespace Umbraco.Cms.Api.Management.ViewModels.DocumentType; + +public class DocumentTypeCollectionReferenceResponseModel : ContentTypeCollectionReferenceResponseModelBase +{ +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs new file mode 100644 index 0000000000..fb772f858f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Media/Collection/MediaCollectionResponseModel.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.MediaType; + +namespace Umbraco.Cms.Api.Management.ViewModels.Media.Collection; + +public class MediaCollectionResponseModel : ContentCollectionResponseModelBase +{ + public MediaTypeCollectionReferenceResponseModel MediaType { get; set; } = new(); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/MediaType/MediaTypeCollectionReferenceResponseModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/MediaType/MediaTypeCollectionReferenceResponseModel.cs new file mode 100644 index 0000000000..9cc5313e7b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/MediaType/MediaTypeCollectionReferenceResponseModel.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Api.Management.ViewModels.ContentType; + +namespace Umbraco.Cms.Api.Management.ViewModels.MediaType; + +public class MediaTypeCollectionReferenceResponseModel : ContentTypeCollectionReferenceResponseModelBase +{ +} diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index ad199b0cbc..b5f54d3416 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -69,6 +69,7 @@ public static partial class Constants public const string Tree = "tree"; public const string RecycleBin = "recycle-bin"; public const string Item = "item"; + public const string Collection = "collection"; } public static class AttributeRouting diff --git a/src/Umbraco.Core/Models/ListViewPagedModel.cs b/src/Umbraco.Core/Models/ListViewPagedModel.cs new file mode 100644 index 0000000000..e84e7d2405 --- /dev/null +++ b/src/Umbraco.Core/Models/ListViewPagedModel.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Core.Models; + +public class ListViewPagedModel + where TContent : IContentBase +{ + public required PagedModel Items { get; init; } + + public required ListViewConfiguration ListViewConfiguration { get; init; } +} diff --git a/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs b/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs index ea267650e3..dc6f6bad95 100644 --- a/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs +++ b/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs @@ -8,5 +8,6 @@ public enum ContentAuthorizationStatus UnauthorizedMissingDescendantAccess, UnauthorizedMissingPathAccess, UnauthorizedMissingRootAccess, - UnauthorizedMissingCulture + UnauthorizedMissingCulture, + UnauthorizedMissingPermissionAccess } diff --git a/src/Umbraco.Core/Services/ContentPermissionService.cs b/src/Umbraco.Core/Services/ContentPermissionService.cs index bef5c1a099..2e24bddd1f 100644 --- a/src/Umbraco.Core/Services/ContentPermissionService.cs +++ b/src/Umbraco.Core/Services/ContentPermissionService.cs @@ -51,7 +51,7 @@ internal sealed class ContentPermissionService : IContentPermissionService return HasPermissionAccess(user, contentItems.Select(c => c.Path), permissionsToCheck) ? ContentAuthorizationStatus.Success - : ContentAuthorizationStatus.UnauthorizedMissingPathAccess; + : ContentAuthorizationStatus.UnauthorizedMissingPermissionAccess; } /// @@ -115,7 +115,7 @@ internal sealed class ContentPermissionService : IContentPermissionService // In this case, we have to use the Root id as path (i.e. -1) since we don't have a content item return HasPermissionAccess(user, new[] { Constants.System.RootString }, permissionsToCheck) ? ContentAuthorizationStatus.Success - : ContentAuthorizationStatus.UnauthorizedMissingPathAccess; + : ContentAuthorizationStatus.UnauthorizedMissingPermissionAccess; } /// @@ -131,7 +131,7 @@ internal sealed class ContentPermissionService : IContentPermissionService // In this case, we have to use the Recycle Bin id as path (i.e. -20) since we don't have a content item return HasPermissionAccess(user, new[] { Constants.System.RecycleBinContentString }, permissionsToCheck) ? ContentAuthorizationStatus.Success - : ContentAuthorizationStatus.UnauthorizedMissingPathAccess; + : ContentAuthorizationStatus.UnauthorizedMissingPermissionAccess; } /// diff --git a/src/Umbraco.Core/Services/IContentListViewService.cs b/src/Umbraco.Core/Services/IContentListViewService.cs new file mode 100644 index 0000000000..7ab16fbbfe --- /dev/null +++ b/src/Umbraco.Core/Services/IContentListViewService.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IContentListViewService +{ + Task?, ContentCollectionOperationStatus>> GetListViewItemsByKeyAsync( + IUser user, + Guid key, + Guid? dataTypeKey, + string orderBy, + string? orderCulture, + Direction orderDirection, + string? filter, + int skip, + int take); +} diff --git a/src/Umbraco.Core/Services/IMediaListViewService.cs b/src/Umbraco.Core/Services/IMediaListViewService.cs new file mode 100644 index 0000000000..b7147f917c --- /dev/null +++ b/src/Umbraco.Core/Services/IMediaListViewService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IMediaListViewService +{ + Task?, ContentCollectionOperationStatus>> GetListViewItemsByKeyAsync( + IUser user, + Guid? key, + Guid? dataTypeKey, + string orderBy, + Direction orderDirection, + string? filter, + int skip, + int take); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentCollectionOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentCollectionOperationStatus.cs new file mode 100644 index 0000000000..9be4348bec --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ContentCollectionOperationStatus.cs @@ -0,0 +1,17 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum ContentCollectionOperationStatus +{ + Success, + CollectionNotFound, + ContentNotCollection, + ContentNotFound, + ContentTypeNotFound, + DataTypeNotCollection, + DataTypeNotContentCollection, + DataTypeNotContentProperty, + DataTypeNotFound, + DataTypeWithoutContentType, + MissingPropertiesInCollectionConfiguration, + OrderByNotPartOfCollectionConfiguration +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index d86db99096..b9137e32d2 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -62,6 +62,8 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddTransient(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Services/ContentListViewServiceBase.cs b/src/Umbraco.Infrastructure/Services/ContentListViewServiceBase.cs new file mode 100644 index 0000000000..9a0975a081 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/ContentListViewServiceBase.cs @@ -0,0 +1,287 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Services; + +internal abstract class ContentListViewServiceBase + where TContent : class, IContentBase + where TContentType : class, IContentTypeComposition + where TContentTypeService : IContentTypeBaseService +{ + private readonly TContentTypeService _contentTypeService; + private readonly IDataTypeService _dataTypeService; + private readonly ISqlContext _sqlContext; + + protected ContentListViewServiceBase(TContentTypeService contentTypeService, IDataTypeService dataTypeService, ISqlContext sqlContext) + { + _contentTypeService = contentTypeService; + _dataTypeService = dataTypeService; + _sqlContext = sqlContext; + } + + protected abstract Guid DefaultListViewKey { get; } + + protected abstract Task> GetPagedChildrenAsync( + int id, + IQuery? filter, + Ordering? ordering, + int skip, + int take); + + protected abstract Task HasAccessToListViewItemAsync(IUser user, Guid key); + + protected async Task?, ContentCollectionOperationStatus>> GetListViewResultAsync( + IUser user, + TContent? content, + Guid? dataTypeKey, + string orderBy, + string? orderCulture, + Direction orderDirection, + string? filter, + int skip, + int take) + { + Attempt configurationAttempt = await GetListViewConfigurationAsync(content?.ContentType.Key, dataTypeKey); + + if (configurationAttempt.Success == false) + { + return Attempt.FailWithStatus?, ContentCollectionOperationStatus>(configurationAttempt.Status, null); + } + + Attempt orderingAttempt = HandleListViewOrdering(configurationAttempt.Result, orderBy, orderCulture, orderDirection); + + if (orderingAttempt.Success == false) + { + return Attempt.FailWithStatus?, ContentCollectionOperationStatus>(orderingAttempt.Status, null); + } + + PagedModel items = await GetAllowedListViewItemsAsync(user, content?.Id ?? Constants.System.Root, filter, orderingAttempt.Result, skip, take); + + var result = new ListViewPagedModel + { + Items = items, + ListViewConfiguration = configurationAttempt.Result!, + }; + + return Attempt.SucceedWithStatus?, ContentCollectionOperationStatus>(ContentCollectionOperationStatus.Success, result); + } + + private Attempt HandleListViewOrdering( + ListViewConfiguration? listViewConfiguration, + string orderBy, + string? orderCulture, + Direction orderDirection) + { + var listViewProperties = listViewConfiguration?.IncludeProperties; + + if (listViewProperties == null || listViewProperties.Length == 0) + { + return Attempt.FailWithStatus(ContentCollectionOperationStatus.MissingPropertiesInCollectionConfiguration, null); + } + + var listViewPropertyAliases = listViewProperties + .Select(p => p.Alias) + .WhereNotNull(); + + // Service layer expects "owner" instead of "creator", so make sure to pass in the correct field + if (orderBy.InvariantEquals("creator")) + { + orderBy = "owner"; + } + + if (listViewPropertyAliases.Contains(orderBy) == false && orderBy.InvariantEquals("name") == false) + { + return Attempt.FailWithStatus(ContentCollectionOperationStatus.OrderByNotPartOfCollectionConfiguration, null); + } + + var orderByCustomField = listViewProperties + .Any(p => p.Alias == orderBy && p.IsSystem == 0); + + var ordering = Ordering.By( + orderBy, + orderDirection, + orderCulture, + orderByCustomField); + + return Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, ordering); + } + + /// + /// Gets the list view configuration from a content type or from a specific list view data type used as a content type property. + /// + /// The key of the content type to check for the configured list view or for its list view properties. + /// The key of the data type used as a list view property on a content type. + /// An attempt indicating if the operation was a success as well as a more detailed . + /// + /// dataTypeKey is ONLY used to check against list views used as content type properties. It is NOT the key for the configured list view on the content type. + /// To get the configured list view for a content type, you shouldn't specify dataTypeKey. + /// + private async Task> GetListViewConfigurationAsync(Guid? contentTypeKey, Guid? dataTypeKey) + { + Attempt contentTypeAttempt = await GetContentTypeForListViewConfigurationAsync(contentTypeKey, dataTypeKey); + + if (contentTypeAttempt.Success == false) + { + return Attempt.FailWithStatus(contentTypeAttempt.Status, null); + } + + // Can be null if we are looking for items at root + TContentType? contentType = contentTypeAttempt.Result; + Attempt listViewConfigurationAttempt; + + if (dataTypeKey.HasValue && contentType != null) + { + listViewConfigurationAttempt = await GetListViewConfigurationFromDataTypeAsync(dataTypeKey.Value, contentType); + } + else + { + listViewConfigurationAttempt = await GetListViewConfigurationFromContentTypeAsync(contentType); + } + + return listViewConfigurationAttempt.Success + ? Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, listViewConfigurationAttempt.Result) + : listViewConfigurationAttempt; + } + + private async Task> GetContentTypeForListViewConfigurationAsync(Guid? contentTypeKey, Guid? dataTypeKey) + { + TContentType? contentType = null; + + if (contentTypeKey.HasValue == false) + { + return dataTypeKey.HasValue + ? Attempt.FailWithStatus(ContentCollectionOperationStatus.DataTypeWithoutContentType, null) + : Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, contentType); // Even though we return null here, this is still valid for the case of querying for items at root. + } + + contentType = await _contentTypeService.GetAsync(contentTypeKey.Value); + + return contentType == null + ? Attempt.FailWithStatus(ContentCollectionOperationStatus.ContentTypeNotFound, null) + : Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, contentType); + } + + private async Task> GetListViewConfigurationFromDataTypeAsync(Guid dataTypeKey, TContentType contentType) + { + IDataType? dataType = await _dataTypeService.GetAsync(dataTypeKey); + if (dataType == null) + { + return Attempt.FailWithStatus(ContentCollectionOperationStatus.DataTypeNotFound, null); + } + + if (dataType.ConfigurationObject is not ListViewConfiguration listViewConfiguration) + { + return Attempt.FailWithStatus(ContentCollectionOperationStatus.DataTypeNotCollection, null); + } + + // Check if the list view data type is a content type property + return contentType.CompositionPropertyTypes.Any(pt => pt.DataTypeKey == dataTypeKey) + ? Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, listViewConfiguration) + : Attempt.FailWithStatus(ContentCollectionOperationStatus.DataTypeNotContentProperty, null); + } + + private async Task> GetListViewConfigurationFromContentTypeAsync(TContentType? contentType) + { + if (contentType?.IsContainer == false) + { + return Attempt.FailWithStatus(ContentCollectionOperationStatus.ContentNotCollection, null); + } + + IDataType? currentListViewDataType = await GetConfiguredListViewDataTypeAsync(contentType); + + return currentListViewDataType?.ConfigurationObject is ListViewConfiguration listViewConfiguration + ? Attempt.SucceedWithStatus(ContentCollectionOperationStatus.Success, listViewConfiguration) + : Attempt.FailWithStatus(ContentCollectionOperationStatus.CollectionNotFound, null); + } + + private async Task GetConfiguredListViewDataTypeAsync(TContentType? contentType) + { + string? listViewSuffix = null; + + // FIXME: Remove. This is a workaround to construct the custom list view name (content type - alias; media type- name) + // until we have the concrete content type + list view binding. + if (DefaultListViewKey == Constants.DataTypes.Guids.ListViewContentGuid) + { + listViewSuffix = contentType?.Alias; + } + else if (DefaultListViewKey == Constants.DataTypes.Guids.ListViewMediaGuid) + { + listViewSuffix = contentType?.Name; + } + + // If we don't have a suffix (content type name or alias), we cannot look for the custom list view by name. + // So return the default one. + if (string.IsNullOrEmpty(listViewSuffix)) + { + return await _dataTypeService.GetAsync(DefaultListViewKey); + } + + // FIXME: Clean up! Get the configured list view from content type once the binding task AB#37205 is done. + // This is a hack based on legacy (same thing can be seen in ListViewContentAppFactory) - we cannot infer the list view associated with a content type otherwise. + // We can use the fact that when a custom list view is removed as the content type list view configuration, the corresponding list view data type gets deleted. + var customListViewName = Constants.Conventions.DataTypes.ListViewPrefix + listViewSuffix; + + return _dataTypeService.GetDataType(customListViewName) ?? await _dataTypeService.GetAsync(DefaultListViewKey); + } + + private async Task> GetAllowedListViewItemsAsync(IUser user, int contentId, string? filter, Ordering? ordering, int skip, int take) + { + var queryFilter = ParseQueryFilter(filter); + + var pagedChildren = await GetPagedChildrenAsync( + contentId, + queryFilter, + ordering, + skip, + take); + + // Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off. + // This has been the case with this functionality in Umbraco for a long time. + var items = await FilterItemsBasedOnAccessAsync(user, pagedChildren.Items); + + var pagedResult = new PagedModel + { + Items = items, + Total = pagedChildren.Total, + }; + + return pagedResult; + } + + private IQuery? ParseQueryFilter(string? filter) + { + // Adding multiple conditions - considering key (as Guid) & name as filter param + Guid.TryParse(filter, out Guid filterAsGuid); + + return filter.IsNullOrWhiteSpace() + ? null + : _sqlContext.Query() + .Where(c => (c.Name != null && c.Name.Contains(filter)) || + c.Key == filterAsGuid); + } + + // TODO: Optimize the way we filter out only the nodes the user is allowed to see - instead of checking one by one + private async Task> FilterItemsBasedOnAccessAsync(IUser user, IEnumerable items) + { + var filteredItems = new List(); + + foreach (TContent item in items) + { + var hasAccess = await HasAccessToListViewItemAsync(user, item.Key); + + if (hasAccess) + { + filteredItems.Add(item); + } + } + + return filteredItems; + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/ContentListViewService.cs b/src/Umbraco.Infrastructure/Services/Implement/ContentListViewService.cs new file mode 100644 index 0000000000..20b5978057 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/ContentListViewService.cs @@ -0,0 +1,98 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.AuthorizationStatus; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Persistence; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +internal sealed class ContentListViewService : ContentListViewServiceBase, IContentListViewService +{ + private readonly IContentService _contentService; + private readonly IContentPermissionService _contentPermissionService; + + protected override Guid DefaultListViewKey => Constants.DataTypes.Guids.ListViewContentGuid; + + public ContentListViewService( + IContentService contentService, + IContentTypeService contentTypeService, + IContentPermissionService contentPermissionService, + IDataTypeService dataTypeService, + ISqlContext sqlContext) + : base(contentTypeService, dataTypeService, sqlContext) + { + _contentService = contentService; + _contentPermissionService = contentPermissionService; + } + + public async Task?, ContentCollectionOperationStatus>> GetListViewItemsByKeyAsync( + IUser user, + Guid key, + Guid? dataTypeKey, + string orderBy, + string? orderCulture, + Direction orderDirection, + string? filter, + int skip, + int take) + { + IContent? content = _contentService.GetById(key); + if (content == null) + { + return Attempt.FailWithStatus?, ContentCollectionOperationStatus>(ContentCollectionOperationStatus.ContentNotFound, null); + } + + return await GetListViewResultAsync(user, content, dataTypeKey, orderBy, orderCulture, orderDirection, filter, skip, take); + } + + protected override async Task> GetPagedChildrenAsync( + int id, + IQuery? filter, + Ordering? ordering, + int skip, + int take) + { + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IEnumerable items = await Task.FromResult(_contentService.GetPagedChildren( + id, + pageNumber, + pageSize, + out var total, + filter, + ordering)); + + var pagedResult = new PagedModel + { + Items = items, + Total = total, + }; + + return pagedResult; + } + + // We can use an authorizer here, as it already handles all the necessary checks for this filtering. + // However, we cannot pass in all the items; we want only the ones that comply, as opposed to + // a general response whether the user has access to all nodes. + protected override async Task HasAccessToListViewItemAsync(IUser user, Guid key) + { + // TODO: Consider if it is better to use IContentPermissionAuthorizer here as people will be able to apply their external authorization + ContentAuthorizationStatus accessStatus = await _contentPermissionService.AuthorizeAccessAsync( + user, + key, + ActionBrowse.ActionLetter); + + // var isAuthorized = await _contentPermissionAuthorizer.IsAuthorizedAsync( + // user, //IPrincipal + // item.Key, + // ActionBrowse.ActionLetter); + // + // return isAuthorized; + + return accessStatus == ContentAuthorizationStatus.Success; + } +} diff --git a/src/Umbraco.Infrastructure/Services/Implement/MediaListViewService.cs b/src/Umbraco.Infrastructure/Services/Implement/MediaListViewService.cs new file mode 100644 index 0000000000..1eadf53ce0 --- /dev/null +++ b/src/Umbraco.Infrastructure/Services/Implement/MediaListViewService.cs @@ -0,0 +1,92 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Persistence.Querying; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.AuthorizationStatus; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Persistence; + +namespace Umbraco.Cms.Infrastructure.Services.Implement; + +internal sealed class MediaListViewService : ContentListViewServiceBase, IMediaListViewService +{ + private readonly IMediaService _mediaService; + private readonly IMediaPermissionService _mediaPermissionService; + + protected override Guid DefaultListViewKey => Constants.DataTypes.Guids.ListViewMediaGuid; + + public MediaListViewService( + IMediaService mediaService, + IMediaTypeService mediaTypeService, + IMediaPermissionService mediaPermissionService, + IDataTypeService dataTypeService, + ISqlContext sqlContext) + : base(mediaTypeService, dataTypeService, sqlContext) + { + _mediaService = mediaService; + _mediaPermissionService = mediaPermissionService; + } + + public async Task?, ContentCollectionOperationStatus>> GetListViewItemsByKeyAsync( + IUser user, + Guid? key, + Guid? dataTypeKey, + string orderBy, + Direction orderDirection, + string? filter, + int skip, + int take) + { + IMedia? media = key.HasValue + ? _mediaService.GetById(key.Value) + : null; + + if (key.HasValue && media is null) + { + return Attempt.FailWithStatus?, ContentCollectionOperationStatus>(ContentCollectionOperationStatus.ContentNotFound, null); + } + + return await GetListViewResultAsync(user, media, dataTypeKey, orderBy, null, orderDirection, filter, skip, take); + } + + protected override async Task> GetPagedChildrenAsync(int id, IQuery? filter, Ordering? ordering, int skip, int take) + { + PaginationHelper.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize); + + IEnumerable items = await Task.FromResult(_mediaService.GetPagedChildren( + id, + pageNumber, + pageSize, + out var total, + filter, + ordering)); + + var pagedResult = new PagedModel + { + Items = items, + Total = total, + }; + + return pagedResult; + } + + // We can use an authorizer here, as it already handles all the necessary checks for this filtering. + // However, we cannot pass in all the items; we want only the ones that comply, as opposed to + // a general response whether the user has access to all nodes. + protected override async Task HasAccessToListViewItemAsync(IUser user, Guid key) + { + // TODO: Consider if it is better to use IMediaPermissionAuthorizer here as people will be able to apply their external authorization + MediaAuthorizationStatus accessStatus = await _mediaPermissionService.AuthorizeAccessAsync( + user, + key); + + // var isAuthorized = await _mediaPermissionAuthorizer.IsAuthorizedAsync( + // user, //IPrincipal + // item.Key); + // + // return isAuthorized; + + return accessStatus == MediaAuthorizationStatus.Success; + } +}