Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion i18n/en_US.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2010,6 +2010,14 @@ ui:
remove: Remove their content
label: Remove all questions, answers, comments, etc.
text: Don’t check this if you wish to only delete the user’s account.
bulk_delete:
action: Delete selected ({{ count }})
title: Delete {{ count }} users
content: Are you sure you want to delete {{ count }} users? This is permanent!
select: Select user
select_all: Select all users on this page
success: "{{ count }} users deleted."
partial: "{{ succeeded }} users deleted; {{ failed }} could not be deleted."
suspend_user:
title: Suspend this user
content: A suspended user can't log in.
Expand All @@ -2028,6 +2036,14 @@ ui:
pending: Pending
filter:
placeholder: "Filter by title, question:id"
bulk_delete:
action: Delete selected ({{ count }})
title: Delete {{ count }} questions
content: Are you sure you want to delete {{ count }} questions?
select: Select question
select_all: Select all questions on this page
success: "{{ count }} questions deleted."
partial: "{{ succeeded }} questions deleted; {{ failed }} could not be deleted."
answers:
page_title: Answers
post: Post
Expand All @@ -2038,6 +2054,14 @@ ui:
change: Change
filter:
placeholder: "Filter by title, answer:id"
bulk_delete:
action: Delete selected ({{ count }})
title: Delete {{ count }} answers
content: Are you sure you want to delete {{ count }} answers?
select: Select answer
select_all: Select all answers on this page
success: "{{ count }} answers deleted."
partial: "{{ succeeded }} answers deleted; {{ failed }} could not be deleted."
general:
page_title: General
name:
Expand Down Expand Up @@ -2488,4 +2512,3 @@ ui:
copy: Copy to clipboard
copied: Copied
external_content_warning: External images/media are not displayed.

25 changes: 24 additions & 1 deletion i18n/zh_CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1969,6 +1969,14 @@ ui:
remove: 移除内容
label: 删除所有问题、 答案、 评论等
text: 如果你只想删除用户账户,请不要选中此项。
bulk_delete:
action: 删除所选项 ({{ count }})
title: 删除 {{ count }} 位用户
content: 确定要删除 {{ count }} 位用户?此操作无法撤销!
select: 选择用户
select_all: 选择本页所有用户
success: "已删除 {{ count }} 位用户。"
partial: "已删除 {{ succeeded }} 位用户;{{ failed }} 位用户无法删除。"
suspend_user:
title: 挂起此用户
content: 被封禁的用户将无法登录。
Expand All @@ -1987,6 +1995,14 @@ ui:
pending: 等待处理
filter:
placeholder: "按标题过滤,问题:id"
bulk_delete:
action: 删除所选项 ({{ count }})
title: 删除 {{ count }} 个问题
content: 确定要删除 {{ count }} 个问题?
select: 选择问题
select_all: 选择本页所有问题
success: "已删除 {{ count }} 个问题。"
partial: "已删除 {{ succeeded }} 个问题;{{ failed }} 个问题无法删除。"
answers:
page_title: 回答
post: 标题
Expand All @@ -1997,6 +2013,14 @@ ui:
change: 更改
filter:
placeholder: "按标题筛选,答案:id"
bulk_delete:
action: 删除所选项 ({{ count }})
title: 删除 {{ count }} 个回答
content: 确定要删除 {{ count }} 个回答?
select: 选择回答
select_all: 选择本页所有回答
success: "已删除 {{ count }} 个回答。"
partial: "已删除 {{ succeeded }} 个回答;{{ failed }} 个回答无法删除。"
general:
page_title: 一般
name:
Expand Down Expand Up @@ -2447,4 +2471,3 @@ ui:
copied: 已复制
external_content_warning: 外部图像/媒体未显示。


24 changes: 24 additions & 0 deletions internal/controller/answer_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,27 @@ func (ac *AnswerController) AdminUpdateAnswerStatus(ctx *gin.Context) {
err := ac.answerService.AdminSetAnswerStatus(ctx, req)
handler.HandleResponse(ctx, err, nil)
}

// AdminDeleteAnswers deletes multiple answers.
// @Summary delete multiple answers
// @Description delete multiple answers
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.DeleteAnswersReq true "DeleteAnswersReq"
// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp}
// @Router /answer/admin/api/answers [delete]
func (ac *AnswerController) AdminDeleteAnswers(ctx *gin.Context) {
req := &schema.DeleteAnswersReq{}
if handler.BindAndCheck(ctx, req) {
return
}
for index, answerID := range req.AnswerIDs {
req.AnswerIDs[index] = uid.DeShortID(answerID)
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)

resp := ac.answerService.AdminDeleteAnswers(ctx, req)
handler.HandleResponse(ctx, nil, resp)
}
24 changes: 24 additions & 0 deletions internal/controller/question_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,30 @@ func (qc *QuestionController) AdminUpdateQuestionStatus(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}

// AdminDeleteQuestions deletes multiple questions.
// @Summary delete multiple questions
// @Description delete multiple questions
// @Tags admin
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param data body schema.DeleteQuestionsReq true "DeleteQuestionsReq"
// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp}
// @Router /answer/admin/api/questions [delete]
func (qc *QuestionController) AdminDeleteQuestions(ctx *gin.Context) {
req := &schema.DeleteQuestionsReq{}
if handler.BindAndCheck(ctx, req) {
return
}
for index, questionID := range req.QuestionIDs {
req.QuestionIDs[index] = uid.DeShortID(questionID)
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)

resp := qc.questionService.AdminDeleteQuestions(ctx, req)
handler.HandleResponse(ctx, nil, resp)
}

// GetQuestionLink get question link
// @Summary get question link
// @Description get question link
Expand Down
25 changes: 25 additions & 0 deletions internal/controller_admin/user_backyard_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,31 @@ func (uc *UserAdminController) UpdateUserStatus(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
}

// DeleteUsers deletes multiple users.
// @Summary delete multiple users
// @Description delete multiple users
// @Security ApiKeyAuth
// @Tags admin
// @Accept json
// @Produce json
// @Param data body schema.DeleteUsersReq true "DeleteUsersReq"
// @Success 200 {object} handler.RespBody{data=schema.BulkDeleteResp}
// @Router /answer/admin/api/users [delete]
func (uc *UserAdminController) DeleteUsers(ctx *gin.Context) {
if u, ok := plugin.GetUserCenter(); ok && u.Description().UserStatusAgentEnabled {
handler.HandleResponse(ctx, errors.Forbidden(reason.ForbiddenError), nil)
return
}
req := &schema.DeleteUsersReq{}
if handler.BindAndCheck(ctx, req) {
return
}

req.LoginUserID = middleware.GetLoginUserIDFromContext(ctx)
resp := uc.userService.DeleteUsers(ctx, req)
handler.HandleResponse(ctx, nil, resp)
}

// UpdateUserRole update user role
// @Summary update user role
// @Description update user role
Expand Down
3 changes: 3 additions & 0 deletions internal/router/answer_api_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,12 +336,15 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) {
r.GET("/question/page", a.questionController.AdminQuestionPage)
r.PUT("/question/status", a.questionController.AdminUpdateQuestionStatus)
r.DELETE("/questions", a.questionController.AdminDeleteQuestions)
r.GET("/answer/page", a.questionController.AdminAnswerPage)
r.PUT("/answer/status", a.answerController.AdminUpdateAnswerStatus)
r.DELETE("/answers", a.answerController.AdminDeleteAnswers)

// user
r.GET("/users/page", a.adminUserController.GetUserPage)
r.PUT("/user/status", a.adminUserController.UpdateUserStatus)
r.DELETE("/users", a.adminUserController.DeleteUsers)
r.PUT("/user/role", a.adminUserController.UpdateUserRole)
r.GET("/user/activation", a.adminUserController.GetUserActivation)
r.POST("/user/activation", a.adminUserController.SendUserActivation)
Expand Down
6 changes: 6 additions & 0 deletions internal/schema/answer_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,9 @@ type AdminUpdateAnswerStatusReq struct {
Status string `validate:"required,oneof=available deleted" json:"status"`
UserID string `json:"-"`
}

// DeleteAnswersReq deletes multiple answers from the admin console.
type DeleteAnswersReq struct {
AnswerIDs []string `validate:"required,min=1,max=500,dive,required" json:"answer_ids"`
UserID string `json:"-"`
}
13 changes: 13 additions & 0 deletions internal/schema/backyard_user_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ func (r *UpdateUserStatusReq) IsSuspended() bool { return r.Status == constant.U
func (r *UpdateUserStatusReq) IsDeleted() bool { return r.Status == constant.UserDeleted }
func (r *UpdateUserStatusReq) IsInactive() bool { return r.Status == constant.UserInactive }

// DeleteUsersReq deletes multiple users from the admin console.
type DeleteUsersReq struct {
UserIDs []string `validate:"required,min=1,max=500,dive,required" json:"user_ids"`
RemoveAllContent bool `json:"remove_all_content"`
LoginUserID string `json:"-"`
}

// BulkDeleteResp reports the IDs that were processed successfully and the IDs that failed.
type BulkDeleteResp struct {
SucceededIDs []string `json:"succeeded_ids"`
FailedIDs []string `json:"failed_ids"`
}

// GetSuspendedUntil calculates the suspended until time based on duration
func (r *UpdateUserStatusReq) GetSuspendedUntil() time.Time {
if !r.IsSuspended() || r.SuspendDuration == "" || r.SuspendDuration == "forever" {
Expand Down
6 changes: 6 additions & 0 deletions internal/schema/question_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,12 @@ type AdminUpdateQuestionStatusReq struct {
UserID string `json:"-"`
}

// DeleteQuestionsReq deletes multiple questions from the admin console.
type DeleteQuestionsReq struct {
QuestionIDs []string `validate:"required,min=1,max=500,dive,required" json:"question_ids"`
UserID string `json:"-"`
}

type PersonalQuestionPageReq struct {
Page int `validate:"omitempty,min=1" form:"page"`
PageSize int `validate:"omitempty,min=1" form:"page_size"`
Expand Down
44 changes: 44 additions & 0 deletions internal/service/bulk_delete/bulk_delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package bulk_delete

import "github.com/apache/answer/internal/schema"

// Execute applies a delete operation once per unique ID and records partial failures.
func Execute(ids []string, deleteFunc func(string) error) *schema.BulkDeleteResp {
resp := &schema.BulkDeleteResp{
SucceededIDs: make([]string, 0),
FailedIDs: make([]string, 0),
}
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}

if err := deleteFunc(id); err != nil {
resp.FailedIDs = append(resp.FailedIDs, id)
continue
}
resp.SucceededIDs = append(resp.SucceededIDs, id)
}
return resp
}
54 changes: 54 additions & 0 deletions internal/service/bulk_delete/bulk_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package bulk_delete

import (
"errors"
"reflect"
"testing"
)

func TestExecute(t *testing.T) {
called := make([]string, 0)
result := Execute([]string{"1", "2", "1", "3"}, func(id string) error {
called = append(called, id)
if id == "2" {
return errors.New("delete failed")
}
return nil
})

if !reflect.DeepEqual(called, []string{"1", "2", "3"}) {
t.Fatalf("unexpected calls: %v", called)
}
if !reflect.DeepEqual(result.SucceededIDs, []string{"1", "3"}) {
t.Fatalf("unexpected successful IDs: %v", result.SucceededIDs)
}
if !reflect.DeepEqual(result.FailedIDs, []string{"2"}) {
t.Fatalf("unexpected failed IDs: %v", result.FailedIDs)
}
}

func TestExecuteReturnsEmptyArrays(t *testing.T) {
result := Execute(nil, func(string) error { return nil })
if result.SucceededIDs == nil || result.FailedIDs == nil {
t.Fatal("expected initialized result arrays")
}
}
17 changes: 16 additions & 1 deletion internal/service/content/answer_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/apache/answer/internal/service/activity_common"
"github.com/apache/answer/internal/service/activityqueue"
answercommon "github.com/apache/answer/internal/service/answer_common"
bulkdelete "github.com/apache/answer/internal/service/bulk_delete"
collectioncommon "github.com/apache/answer/internal/service/collection_common"
"github.com/apache/answer/internal/service/export"
"github.com/apache/answer/internal/service/noticequeue"
Expand Down Expand Up @@ -636,6 +637,9 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A
if !exist {
return errors.BadRequest(reason.AnswerNotFound)
}
if answerInfo.Status == setStatus {
return nil
}

if setStatus == entity.AnswerStatusDeleted {
if err := as.RemoveAnswer(ctx, &schema.RemoveAnswerReq{
Expand All @@ -657,7 +661,7 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A
}

// recover
if setStatus == entity.QuestionStatusAvailable && answerInfo.Status == entity.QuestionStatusDeleted {
if setStatus == entity.AnswerStatusAvailable && answerInfo.Status == entity.AnswerStatusDeleted {
if err := as.RecoverAnswer(ctx, &schema.RecoverAnswerReq{
AnswerID: req.AnswerID,
UserID: req.UserID,
Expand All @@ -676,6 +680,17 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A
return nil
}

// AdminDeleteAnswers deletes answers one at a time to preserve the existing admin delete side effects.
func (as *AnswerService) AdminDeleteAnswers(ctx context.Context, req *schema.DeleteAnswersReq) *schema.BulkDeleteResp {
return bulkdelete.Execute(req.AnswerIDs, func(answerID string) error {
return as.AdminSetAnswerStatus(ctx, &schema.AdminUpdateAnswerStatusReq{
AnswerID: answerID,
Status: "deleted",
UserID: req.UserID,
})
})
}

func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListReq) ([]*schema.AnswerInfo, int64, error) {
list := make([]*schema.AnswerInfo, 0)
questionInfo, exist, err := as.questionRepo.GetQuestion(ctx, req.QuestionID)
Expand Down
Loading