Bulk delete functionality for management api (#14735)

* Bulk delete

* Bulk delete

* Added bulk delete user groups

* Clean

---------

Co-authored-by: Nikolaj <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Bjarke Berg
2023-08-29 15:51:20 +02:00
committed by GitHub
parent a07d29b76a
commit 4f5fc0b8a1
15 changed files with 175 additions and 38 deletions

View File

@@ -0,0 +1,35 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.User;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.User;
[ApiVersion("1.0")]
public class BulkDeleteUsersController : UserControllerBase
{
private readonly IUserService _userService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public BulkDeleteUsersController(IUserService userService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_userService = userService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpDelete]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> DeleteUsers(DeleteUsersRequestModel model)
{
UserOperationStatus result = await _userService.DeleteAsync(CurrentUserKey(_backOfficeSecurityAccessor), model.UserIds);
return result is UserOperationStatus.Success
? Ok()
: UserOperationStatusResult(result);
}
}

View File

@@ -1,5 +1,6 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -8,15 +9,20 @@ namespace Umbraco.Cms.Api.Management.Controllers.User;
[ApiVersion("1.0")]
public class DeleteUserController : UserControllerBase
{
public DeleteUserController(IUserService userService) => _userService = userService;
public DeleteUserController(IUserService userService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_userService = userService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
private readonly IUserService _userService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
[MapToApiVersion("1.0")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id)
{
UserOperationStatus result = await _userService.DeleteAsync(id);
UserOperationStatus result = await _userService.DeleteAsync(CurrentUserKey(_backOfficeSecurityAccessor), id);
return result is UserOperationStatus.Success
? Ok()

View File

@@ -64,6 +64,10 @@ public abstract class UserControllerBase : ManagementApiControllerBase
.WithTitle("Cannot disable")
.WithDetail("A user cannot disable itself.")
.Build()),
UserOperationStatus.CannotDeleteSelf => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Cannot delete")
.WithDetail("A user cannot delete itself.")
.Build()),
UserOperationStatus.OldPasswordRequired => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Old password required")
.WithDetail("The old password is required to change the password of the specified user.")

View File

@@ -0,0 +1,33 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroup;
[ApiVersion("1.0")]
public class BulkDeleteUserGroupsController : UserGroupControllerBase
{
private readonly IUserGroupService _userGroupService;
public BulkDeleteUserGroupsController(IUserGroupService userGroupService)
{
_userGroupService = userGroupService;
}
[HttpDelete]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> BulkDelete(DeleteUserGroupsRequestModel model)
{
Attempt<UserGroupOperationStatus> result = await _userGroupService.DeleteAsync(model.UserGroupIds);
return result.Success
? Ok()
: UserGroupOperationStatusResult(result.Result);
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.User;
public class DeleteUsersRequestModel
{
public HashSet<Guid> UserIds { get; set; } = new();
}

View File

@@ -3,4 +3,4 @@
public class DisableUserRequestModel
{
public HashSet<Guid> UserIds { get; set; } = new();
}
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroup;
public class DeleteUserGroupsRequestModel
{
public HashSet<Guid> UserGroupIds { get; set; } = new();
}

View File

@@ -12,4 +12,9 @@ public sealed class UserGroupDeletedNotification : DeletedNotification<IUserGrou
: base(target, messages)
{
}
public UserGroupDeletedNotification(IEnumerable<IUserGroup> target, EventMessages messages)
: base(target, messages)
{
}
}

View File

@@ -82,9 +82,10 @@ public interface IUserGroupService
/// <summary>
/// Deletes a UserGroup
/// </summary>
/// <param name="key">The key of the user group to delete.</param>
/// <param name="userGroupKeys">The keys of the user groups to delete.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<UserGroupOperationStatus>> DeleteAsync(Guid key);
Task<Attempt<UserGroupOperationStatus>> DeleteAsync(ISet<Guid> userGroupKeys);
Task<Attempt<UserGroupOperationStatus>> DeleteAsync(Guid userGroupKey) => DeleteAsync(new HashSet<Guid>(){userGroupKey});
/// <summary>
/// Updates the users to have the groups specified.

View File

@@ -67,7 +67,9 @@ public interface IUserService : IMembershipUserService
Task<UserOperationStatus> SetAvatarAsync(Guid userKey, Guid temporaryFileKey);
Task<UserOperationStatus> DeleteAsync(Guid key);
Task<UserOperationStatus> DeleteAsync(Guid userKey, ISet<Guid> keys);
Task<UserOperationStatus> DeleteAsync(Guid userKey, Guid key) => DeleteAsync(userKey, new HashSet<Guid> { key });
Task<UserOperationStatus> DisableAsync(Guid userKey, ISet<Guid> keys);

View File

@@ -18,5 +18,5 @@ public enum UserGroupOperationStatus
LanguageNotFound,
NameTooLong,
AliasTooLong,
MissingName,
MissingName
}

View File

@@ -19,6 +19,7 @@ public enum UserOperationStatus
CannotInvite,
CannotDelete,
CannotDisableSelf,
CannotDeleteSelf,
CannotDisableInvitedUser,
OldPasswordRequired,
InvalidAvatar,

View File

@@ -135,35 +135,48 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
}
/// <inheritdoc/>
public async Task<Attempt<UserGroupOperationStatus>> DeleteAsync(Guid key)
public async Task<Attempt<UserGroupOperationStatus>> DeleteAsync(ISet<Guid> keys)
{
IUserGroup? userGroup = await GetAsync(key);
Attempt<UserGroupOperationStatus> validationResult = ValidateUserGroupDeletion(userGroup);
if (validationResult.Success is false)
if (keys.Any() is false)
{
return validationResult;
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
EventMessages eventMessages = EventMessagesFactory.Get();
IUserGroup[] userGroupsToDelete = (await GetAsync(keys)).ToArray();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
if (userGroupsToDelete.Length != keys.Count)
{
var deletingNotification = new UserGroupDeletingNotification(userGroup!, eventMessages);
return Attempt.Fail(UserGroupOperationStatus.NotFound);
}
if (await scope.Notifications.PublishCancelableAsync(deletingNotification))
foreach (IUserGroup userGroup in userGroupsToDelete)
{
Attempt<UserGroupOperationStatus> validationResult = ValidateUserGroupDeletion(userGroup);
if (validationResult.Success is false)
{
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.CancelledByNotification);
return validationResult;
}
_userGroupRepository.Delete(userGroup!);
scope.Notifications.Publish(new UserGroupDeletedNotification(userGroup!, eventMessages).WithStateFrom(deletingNotification));
scope.Complete();
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new UserGroupDeletingNotification(userGroupsToDelete, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(deletingNotification))
{
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.CancelledByNotification);
}
foreach (IUserGroup userGroup in userGroupsToDelete)
{
_userGroupRepository.Delete(userGroup);
}
scope.Notifications.Publish(new UserGroupDeletedNotification(userGroupsToDelete, eventMessages).WithStateFrom(deletingNotification));
scope.Complete();
return Attempt.Succeed(UserGroupOperationStatus.Success);
}

View File

@@ -1324,24 +1324,49 @@ internal class UserService : RepositoryService, IUserService
};
}
public async Task<UserOperationStatus> DeleteAsync(Guid key)
public async Task<UserOperationStatus> DeleteAsync(Guid userKey, ISet<Guid> keys)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IUser? user = await GetAsync(key);
if(keys.Any() is false)
{
return UserOperationStatus.Success;
}
if (user is null)
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IUser? performingUser = await GetAsync(userKey);
if (performingUser is null)
{
return UserOperationStatus.MissingUser;
}
if (keys.Contains(performingUser.Key))
{
return UserOperationStatus.CannotDeleteSelf;
}
IServiceScope serviceScope = _serviceScopeFactory.CreateScope();
IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService<IBackOfficeUserStore>();
IUser[] usersToDisable = (await userStore.GetUsersAsync(keys.ToArray())).ToArray();
if (usersToDisable.Length != keys.Count)
{
return UserOperationStatus.UserNotFound;
}
// Check user hasn't logged in. If they have they may have made content changes which will mean
// the Id is associated with audit trails, versions etc. and can't be removed.
if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime))
foreach (IUser user in usersToDisable)
{
return UserOperationStatus.CannotDelete;
}
// Check user hasn't logged in. If they have they may have made content changes which will mean
// the Id is associated with audit trails, versions etc. and can't be removed.
if (user.LastLoginDate is not null && user.LastLoginDate != default(DateTime))
{
return UserOperationStatus.CannotDelete;
}
Delete(user, true);
user.IsApproved = false;
user.InvitedDate = null;
Delete(user, true);
}
scope.Complete();
return UserOperationStatus.Success;

View File

@@ -12,7 +12,7 @@ public partial class UserServiceCrudTests
public async Task Delete_Returns_Not_Found_If_Not_Found()
{
var userService = CreateUserService();
var result = await userService.DeleteAsync(Guid.NewGuid());
var result = await userService.DeleteAsync(Constants.Security.SuperUserKey, Guid.NewGuid());
Assert.AreEqual(UserOperationStatus.UserNotFound, result);
}
@@ -36,7 +36,7 @@ public partial class UserServiceCrudTests
createdUser!.LastLoginDate = DateTime.Now;
userService.Save(createdUser);
var result = await userService.DeleteAsync(createdUser.Key);
var result = await userService.DeleteAsync(Constants.Security.SuperUserKey, createdUser.Key);
Assert.AreEqual(UserOperationStatus.CannotDelete, result);
// Asset that it is in fact not deleted
@@ -61,7 +61,7 @@ public partial class UserServiceCrudTests
var creationResult = await userService.CreateAsync(Constants.Security.SuperUserKey, userCreateModel, true);
Assert.IsTrue(creationResult.Success);
var deletionResult = await userService.DeleteAsync(creationResult.Result.CreatedUser!.Key);
var deletionResult = await userService.DeleteAsync(Constants.Security.SuperUserKey, creationResult.Result.CreatedUser!.Key);
Assert.AreEqual(UserOperationStatus.Success, deletionResult);
// Make sure it's actually deleted
var postDeletedUser = await userService.GetAsync(creationResult.Result.CreatedUser.Key);