feat(tasks): hide finished task records from the owner's list (#2227)

Users can now delete finished task records; the row is kept so admins
retain history.

- schema: Task.hidden flag (additive, auto-migrated)
- inventory: ListTaskArgs.ExcludeHidden filter + HideByIDs scoped to
  the owner
- workflow: DELETE /workflow/:id hides completed/error/canceled tasks;
  running tasks must be canceled first
- task detail: delete action on finished tasks
- tests: hide + list exclusion, running-task and cross-owner rejection

Authored By: TDvorak <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent c3200aaf77
commit 5817da287a

File diff suppressed because one or more lines are too long

@ -455,6 +455,7 @@ var (
{Name: "public_state", Type: field.TypeJSON}, {Name: "public_state", Type: field.TypeJSON},
{Name: "private_state", Type: field.TypeString, Nullable: true, Size: 2147483647}, {Name: "private_state", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "correlation_id", Type: field.TypeUUID, Nullable: true}, {Name: "correlation_id", Type: field.TypeUUID, Nullable: true},
{Name: "hidden", Type: field.TypeBool, Default: false},
{Name: "user_tasks", Type: field.TypeInt, Nullable: true}, {Name: "user_tasks", Type: field.TypeInt, Nullable: true},
} }
// TasksTable holds the schema information for the "tasks" table. // TasksTable holds the schema information for the "tasks" table.
@ -465,7 +466,7 @@ var (
ForeignKeys: []*schema.ForeignKey{ ForeignKeys: []*schema.ForeignKey{
{ {
Symbol: "tasks_users_tasks", Symbol: "tasks_users_tasks",
Columns: []*schema.Column{TasksColumns[9]}, Columns: []*schema.Column{TasksColumns[10]},
RefColumns: []*schema.Column{UsersColumns[0]}, RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull, OnDelete: schema.SetNull,
}, },

@ -14158,6 +14158,7 @@ type TaskMutation struct {
public_state **types.TaskPublicState public_state **types.TaskPublicState
private_state *string private_state *string
correlation_id *uuid.UUID correlation_id *uuid.UUID
hidden *bool
clearedFields map[string]struct{} clearedFields map[string]struct{}
user *int user *int
cleareduser bool cleareduser bool
@ -14640,6 +14641,42 @@ func (m *TaskMutation) ResetUserTasks() {
delete(m.clearedFields, task.FieldUserTasks) delete(m.clearedFields, task.FieldUserTasks)
} }
// SetHidden sets the "hidden" field.
func (m *TaskMutation) SetHidden(b bool) {
m.hidden = &b
}
// Hidden returns the value of the "hidden" field in the mutation.
func (m *TaskMutation) Hidden() (r bool, exists bool) {
v := m.hidden
if v == nil {
return
}
return *v, true
}
// OldHidden returns the old "hidden" field's value of the Task entity.
// If the Task object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *TaskMutation) OldHidden(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldHidden is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldHidden requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldHidden: %w", err)
}
return oldValue.Hidden, nil
}
// ResetHidden resets all changes to the "hidden" field.
func (m *TaskMutation) ResetHidden() {
m.hidden = nil
}
// SetUserID sets the "user" edge to the User entity by id. // SetUserID sets the "user" edge to the User entity by id.
func (m *TaskMutation) SetUserID(id int) { func (m *TaskMutation) SetUserID(id int) {
m.user = &id m.user = &id
@ -14714,7 +14751,7 @@ func (m *TaskMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call // order to get all numeric fields that were incremented/decremented, call
// AddedFields(). // AddedFields().
func (m *TaskMutation) Fields() []string { func (m *TaskMutation) Fields() []string {
fields := make([]string, 0, 9) fields := make([]string, 0, 10)
if m.created_at != nil { if m.created_at != nil {
fields = append(fields, task.FieldCreatedAt) fields = append(fields, task.FieldCreatedAt)
} }
@ -14742,6 +14779,9 @@ func (m *TaskMutation) Fields() []string {
if m.user != nil { if m.user != nil {
fields = append(fields, task.FieldUserTasks) fields = append(fields, task.FieldUserTasks)
} }
if m.hidden != nil {
fields = append(fields, task.FieldHidden)
}
return fields return fields
} }
@ -14768,6 +14808,8 @@ func (m *TaskMutation) Field(name string) (ent.Value, bool) {
return m.CorrelationID() return m.CorrelationID()
case task.FieldUserTasks: case task.FieldUserTasks:
return m.UserTasks() return m.UserTasks()
case task.FieldHidden:
return m.Hidden()
} }
return nil, false return nil, false
} }
@ -14795,6 +14837,8 @@ func (m *TaskMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldCorrelationID(ctx) return m.OldCorrelationID(ctx)
case task.FieldUserTasks: case task.FieldUserTasks:
return m.OldUserTasks(ctx) return m.OldUserTasks(ctx)
case task.FieldHidden:
return m.OldHidden(ctx)
} }
return nil, fmt.Errorf("unknown Task field %s", name) return nil, fmt.Errorf("unknown Task field %s", name)
} }
@ -14867,6 +14911,13 @@ func (m *TaskMutation) SetField(name string, value ent.Value) error {
} }
m.SetUserTasks(v) m.SetUserTasks(v)
return nil return nil
case task.FieldHidden:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetHidden(v)
return nil
} }
return fmt.Errorf("unknown Task field %s", name) return fmt.Errorf("unknown Task field %s", name)
} }
@ -14973,6 +15024,9 @@ func (m *TaskMutation) ResetField(name string) error {
case task.FieldUserTasks: case task.FieldUserTasks:
m.ResetUserTasks() m.ResetUserTasks()
return nil return nil
case task.FieldHidden:
m.ResetHidden()
return nil
} }
return fmt.Errorf("unknown Task field %s", name) return fmt.Errorf("unknown Task field %s", name)
} }

@ -383,6 +383,10 @@ func init() {
task.DefaultUpdatedAt = taskDescUpdatedAt.Default.(func() time.Time) task.DefaultUpdatedAt = taskDescUpdatedAt.Default.(func() time.Time)
// task.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. // task.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time) task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time)
// taskDescHidden is the schema descriptor for hidden field.
taskDescHidden := taskFields[6].Descriptor()
// task.DefaultHidden holds the default value on creation for the hidden field.
task.DefaultHidden = taskDescHidden.Default.(bool)
userMixin := schema.User{}.Mixin() userMixin := schema.User{}.Mixin()
userMixinHooks0 := userMixin[0].Hooks() userMixinHooks0 := userMixin[0].Hooks()
user.Hooks[0] = userMixinHooks0[0] user.Hooks[0] = userMixinHooks0[0]

@ -26,6 +26,8 @@ func (Task) Fields() []ent.Field {
Optional(). Optional().
Immutable(), Immutable(),
field.Int("user_tasks").Optional(), field.Int("user_tasks").Optional(),
// hidden is set when the owner deletes the task record; admin views keep it.
field.Bool("hidden").Default(false),
} }
} }

@ -39,6 +39,8 @@ type Task struct {
CorrelationID uuid.UUID `json:"correlation_id,omitempty"` CorrelationID uuid.UUID `json:"correlation_id,omitempty"`
// UserTasks holds the value of the "user_tasks" field. // UserTasks holds the value of the "user_tasks" field.
UserTasks int `json:"user_tasks,omitempty"` UserTasks int `json:"user_tasks,omitempty"`
// Hidden holds the value of the "hidden" field.
Hidden bool `json:"hidden,omitempty"`
// Edges holds the relations/edges for other nodes in the graph. // Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the TaskQuery when eager-loading is set. // The values are being populated by the TaskQuery when eager-loading is set.
Edges TaskEdges `json:"edges"` Edges TaskEdges `json:"edges"`
@ -74,6 +76,8 @@ func (*Task) scanValues(columns []string) ([]any, error) {
switch columns[i] { switch columns[i] {
case task.FieldPublicState: case task.FieldPublicState:
values[i] = new([]byte) values[i] = new([]byte)
case task.FieldHidden:
values[i] = new(sql.NullBool)
case task.FieldID, task.FieldUserTasks: case task.FieldID, task.FieldUserTasks:
values[i] = new(sql.NullInt64) values[i] = new(sql.NullInt64)
case task.FieldType, task.FieldStatus, task.FieldPrivateState: case task.FieldType, task.FieldStatus, task.FieldPrivateState:
@ -160,6 +164,12 @@ func (t *Task) assignValues(columns []string, values []any) error {
} else if value.Valid { } else if value.Valid {
t.UserTasks = int(value.Int64) t.UserTasks = int(value.Int64)
} }
case task.FieldHidden:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field hidden", values[i])
} else if value.Valid {
t.Hidden = value.Bool
}
default: default:
t.selectValues.Set(columns[i], values[i]) t.selectValues.Set(columns[i], values[i])
} }
@ -229,6 +239,9 @@ func (t *Task) String() string {
builder.WriteString(", ") builder.WriteString(", ")
builder.WriteString("user_tasks=") builder.WriteString("user_tasks=")
builder.WriteString(fmt.Sprintf("%v", t.UserTasks)) builder.WriteString(fmt.Sprintf("%v", t.UserTasks))
builder.WriteString(", ")
builder.WriteString("hidden=")
builder.WriteString(fmt.Sprintf("%v", t.Hidden))
builder.WriteByte(')') builder.WriteByte(')')
return builder.String() return builder.String()
} }

@ -34,6 +34,8 @@ const (
FieldCorrelationID = "correlation_id" FieldCorrelationID = "correlation_id"
// FieldUserTasks holds the string denoting the user_tasks field in the database. // FieldUserTasks holds the string denoting the user_tasks field in the database.
FieldUserTasks = "user_tasks" FieldUserTasks = "user_tasks"
// FieldHidden holds the string denoting the hidden field in the database.
FieldHidden = "hidden"
// EdgeUser holds the string denoting the user edge name in mutations. // EdgeUser holds the string denoting the user edge name in mutations.
EdgeUser = "user" EdgeUser = "user"
// Table holds the table name of the task in the database. // Table holds the table name of the task in the database.
@ -59,6 +61,7 @@ var Columns = []string{
FieldPrivateState, FieldPrivateState,
FieldCorrelationID, FieldCorrelationID,
FieldUserTasks, FieldUserTasks,
FieldHidden,
} }
// ValidColumn reports if the column name is valid (part of the table columns). // ValidColumn reports if the column name is valid (part of the table columns).
@ -85,6 +88,8 @@ var (
DefaultUpdatedAt func() time.Time DefaultUpdatedAt func() time.Time
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
UpdateDefaultUpdatedAt func() time.Time UpdateDefaultUpdatedAt func() time.Time
// DefaultHidden holds the default value on creation for the "hidden" field.
DefaultHidden bool
) )
// Status defines the type for the "status" enum field. // Status defines the type for the "status" enum field.
@ -165,6 +170,11 @@ func ByUserTasks(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUserTasks, opts...).ToFunc() return sql.OrderByField(FieldUserTasks, opts...).ToFunc()
} }
// ByHidden orders the results by the hidden field.
func ByHidden(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldHidden, opts...).ToFunc()
}
// ByUserField orders the results by user field. // ByUserField orders the results by user field.
func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) { return func(s *sql.Selector) {

@ -91,6 +91,11 @@ func UserTasks(v int) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldUserTasks, v)) return predicate.Task(sql.FieldEQ(FieldUserTasks, v))
} }
// Hidden applies equality check predicate on the "hidden" field. It's identical to HiddenEQ.
func Hidden(v bool) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldHidden, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field. // CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Task { func CreatedAtEQ(v time.Time) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldCreatedAt, v)) return predicate.Task(sql.FieldEQ(FieldCreatedAt, v))
@ -461,6 +466,16 @@ func UserTasksNotNil() predicate.Task {
return predicate.Task(sql.FieldNotNull(FieldUserTasks)) return predicate.Task(sql.FieldNotNull(FieldUserTasks))
} }
// HiddenEQ applies the EQ predicate on the "hidden" field.
func HiddenEQ(v bool) predicate.Task {
return predicate.Task(sql.FieldEQ(FieldHidden, v))
}
// HiddenNEQ applies the NEQ predicate on the "hidden" field.
func HiddenNEQ(v bool) predicate.Task {
return predicate.Task(sql.FieldNEQ(FieldHidden, v))
}
// HasUser applies the HasEdge predicate on the "user" edge. // HasUser applies the HasEdge predicate on the "user" edge.
func HasUser() predicate.Task { func HasUser() predicate.Task {
return predicate.Task(func(s *sql.Selector) { return predicate.Task(func(s *sql.Selector) {

@ -135,6 +135,20 @@ func (tc *TaskCreate) SetNillableUserTasks(i *int) *TaskCreate {
return tc return tc
} }
// SetHidden sets the "hidden" field.
func (tc *TaskCreate) SetHidden(b bool) *TaskCreate {
tc.mutation.SetHidden(b)
return tc
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tc *TaskCreate) SetNillableHidden(b *bool) *TaskCreate {
if b != nil {
tc.SetHidden(*b)
}
return tc
}
// SetUserID sets the "user" edge to the User entity by ID. // SetUserID sets the "user" edge to the User entity by ID.
func (tc *TaskCreate) SetUserID(id int) *TaskCreate { func (tc *TaskCreate) SetUserID(id int) *TaskCreate {
tc.mutation.SetUserID(id) tc.mutation.SetUserID(id)
@ -209,6 +223,10 @@ func (tc *TaskCreate) defaults() error {
v := task.DefaultStatus v := task.DefaultStatus
tc.mutation.SetStatus(v) tc.mutation.SetStatus(v)
} }
if _, ok := tc.mutation.Hidden(); !ok {
v := task.DefaultHidden
tc.mutation.SetHidden(v)
}
return nil return nil
} }
@ -234,6 +252,9 @@ func (tc *TaskCreate) check() error {
if _, ok := tc.mutation.PublicState(); !ok { if _, ok := tc.mutation.PublicState(); !ok {
return &ValidationError{Name: "public_state", err: errors.New(`ent: missing required field "Task.public_state"`)} return &ValidationError{Name: "public_state", err: errors.New(`ent: missing required field "Task.public_state"`)}
} }
if _, ok := tc.mutation.Hidden(); !ok {
return &ValidationError{Name: "hidden", err: errors.New(`ent: missing required field "Task.hidden"`)}
}
return nil return nil
} }
@ -300,6 +321,10 @@ func (tc *TaskCreate) createSpec() (*Task, *sqlgraph.CreateSpec) {
_spec.SetField(task.FieldCorrelationID, field.TypeUUID, value) _spec.SetField(task.FieldCorrelationID, field.TypeUUID, value)
_node.CorrelationID = value _node.CorrelationID = value
} }
if value, ok := tc.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
_node.Hidden = value
}
if nodes := tc.mutation.UserIDs(); len(nodes) > 0 { if nodes := tc.mutation.UserIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O, Rel: sqlgraph.M2O,
@ -471,6 +496,18 @@ func (u *TaskUpsert) ClearUserTasks() *TaskUpsert {
return u return u
} }
// SetHidden sets the "hidden" field.
func (u *TaskUpsert) SetHidden(v bool) *TaskUpsert {
u.Set(task.FieldHidden, v)
return u
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsert) UpdateHidden() *TaskUpsert {
u.SetExcluded(task.FieldHidden)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create. // UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using: // Using this option is equivalent to using:
// //
@ -638,6 +675,20 @@ func (u *TaskUpsertOne) ClearUserTasks() *TaskUpsertOne {
}) })
} }
// SetHidden sets the "hidden" field.
func (u *TaskUpsertOne) SetHidden(v bool) *TaskUpsertOne {
return u.Update(func(s *TaskUpsert) {
s.SetHidden(v)
})
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsertOne) UpdateHidden() *TaskUpsertOne {
return u.Update(func(s *TaskUpsert) {
s.UpdateHidden()
})
}
// Exec executes the query. // Exec executes the query.
func (u *TaskUpsertOne) Exec(ctx context.Context) error { func (u *TaskUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 { if len(u.create.conflict) == 0 {
@ -976,6 +1027,20 @@ func (u *TaskUpsertBulk) ClearUserTasks() *TaskUpsertBulk {
}) })
} }
// SetHidden sets the "hidden" field.
func (u *TaskUpsertBulk) SetHidden(v bool) *TaskUpsertBulk {
return u.Update(func(s *TaskUpsert) {
s.SetHidden(v)
})
}
// UpdateHidden sets the "hidden" field to the value that was provided on create.
func (u *TaskUpsertBulk) UpdateHidden() *TaskUpsertBulk {
return u.Update(func(s *TaskUpsert) {
s.UpdateHidden()
})
}
// Exec executes the query. // Exec executes the query.
func (u *TaskUpsertBulk) Exec(ctx context.Context) error { func (u *TaskUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil { if u.create.err != nil {

@ -130,6 +130,20 @@ func (tu *TaskUpdate) ClearUserTasks() *TaskUpdate {
return tu return tu
} }
// SetHidden sets the "hidden" field.
func (tu *TaskUpdate) SetHidden(b bool) *TaskUpdate {
tu.mutation.SetHidden(b)
return tu
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tu *TaskUpdate) SetNillableHidden(b *bool) *TaskUpdate {
if b != nil {
tu.SetHidden(*b)
}
return tu
}
// SetUserID sets the "user" edge to the User entity by ID. // SetUserID sets the "user" edge to the User entity by ID.
func (tu *TaskUpdate) SetUserID(id int) *TaskUpdate { func (tu *TaskUpdate) SetUserID(id int) *TaskUpdate {
tu.mutation.SetUserID(id) tu.mutation.SetUserID(id)
@ -251,6 +265,9 @@ func (tu *TaskUpdate) sqlSave(ctx context.Context) (n int, err error) {
if tu.mutation.CorrelationIDCleared() { if tu.mutation.CorrelationIDCleared() {
_spec.ClearField(task.FieldCorrelationID, field.TypeUUID) _spec.ClearField(task.FieldCorrelationID, field.TypeUUID)
} }
if value, ok := tu.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
}
if tu.mutation.UserCleared() { if tu.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O, Rel: sqlgraph.M2O,
@ -400,6 +417,20 @@ func (tuo *TaskUpdateOne) ClearUserTasks() *TaskUpdateOne {
return tuo return tuo
} }
// SetHidden sets the "hidden" field.
func (tuo *TaskUpdateOne) SetHidden(b bool) *TaskUpdateOne {
tuo.mutation.SetHidden(b)
return tuo
}
// SetNillableHidden sets the "hidden" field if the given value is not nil.
func (tuo *TaskUpdateOne) SetNillableHidden(b *bool) *TaskUpdateOne {
if b != nil {
tuo.SetHidden(*b)
}
return tuo
}
// SetUserID sets the "user" edge to the User entity by ID. // SetUserID sets the "user" edge to the User entity by ID.
func (tuo *TaskUpdateOne) SetUserID(id int) *TaskUpdateOne { func (tuo *TaskUpdateOne) SetUserID(id int) *TaskUpdateOne {
tuo.mutation.SetUserID(id) tuo.mutation.SetUserID(id)
@ -551,6 +582,9 @@ func (tuo *TaskUpdateOne) sqlSave(ctx context.Context) (_node *Task, err error)
if tuo.mutation.CorrelationIDCleared() { if tuo.mutation.CorrelationIDCleared() {
_spec.ClearField(task.FieldCorrelationID, field.TypeUUID) _spec.ClearField(task.FieldCorrelationID, field.TypeUUID)
} }
if value, ok := tuo.mutation.Hidden(); ok {
_spec.SetField(task.FieldHidden, field.TypeBool, value)
}
if tuo.mutation.UserCleared() { if tuo.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{ edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O, Rel: sqlgraph.M2O,

@ -102,5 +102,6 @@
"50005": "Internal error.", "50005": "Internal error.",
"50010": "Desired node is unavailable.", "50010": "Desired node is unavailable.",
"50011": "Failed to query file metadata." "50011": "Failed to query file metadata."
} },
"delete": "Delete"
} }

@ -102,5 +102,6 @@
"50005": "内部错误 ({{message}})", "50005": "内部错误 ({{message}})",
"50010": "目标节点不可用", "50010": "目标节点不可用",
"50011": "文件元信息查询失败" "50011": "文件元信息查询失败"
} },
"delete": "删除"
} }

@ -935,6 +935,22 @@ export function sendCancelTask(id: string): ThunkResponse<undefined> {
}; };
} }
export function sendDeleteTask(id: string): ThunkResponse<undefined> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/workflow/${id}`,
{
method: "DELETE",
},
{
...defaultOpts,
},
),
);
};
}
export function sendRetryTask(id: string): ThunkResponse<undefined> { export function sendRetryTask(id: string): ThunkResponse<undefined> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -12,7 +12,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { sendCancelTask, sendRetryTask } from "../../../api/api.ts"; import { sendCancelTask, sendDeleteTask, sendRetryTask } from "../../../api/api.ts";
import { TaskResponse, TaskStatus } from "../../../api/workflow.ts"; import { TaskResponse, TaskStatus } from "../../../api/workflow.ts";
import { useAppDispatch } from "../../../redux/hooks.ts"; import { useAppDispatch } from "../../../redux/hooks.ts";
import { SecondaryLoadingButton, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx"; import { SecondaryLoadingButton, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
@ -48,7 +48,19 @@ const TaskDetail = ({ task, downloading, onRetried }: TaskDetailProps) => {
.finally(() => setCanceling(false)); .finally(() => setCanceling(false));
}; };
const [deleting, setDeleting] = useState(false);
const deleteRecord = () => {
setDeleting(true);
dispatch(sendDeleteTask(task.id))
.then(() => onRetried?.())
.catch(() => {})
.finally(() => setDeleting(false));
};
const cancelable = task.status == TaskStatus.queued || task.status == TaskStatus.suspending; const cancelable = task.status == TaskStatus.queued || task.status == TaskStatus.suspending;
const deletable =
task.status == TaskStatus.completed || task.status == TaskStatus.error || task.status == TaskStatus.canceled;
return ( return (
<Stack spacing={2}> <Stack spacing={2}>
<Stack spacing={1}> <Stack spacing={1}>
@ -83,6 +95,18 @@ const TaskDetail = ({ task, downloading, onRetried }: TaskDetailProps) => {
{t("common:cancel")} {t("common:cancel")}
</SecondaryLoadingButton> </SecondaryLoadingButton>
)} )}
{deletable && (
<SecondaryLoadingButton
size="small"
variant="outlined"
color="error"
loading={deleting}
onClick={deleteRecord}
sx={{ alignSelf: "flex-start" }}
>
{t("common:delete")}
</SecondaryLoadingButton>
)}
{task.status == TaskStatus.error && ( {task.status == TaskStatus.error && (
<Alert <Alert
severity={"error"} severity={"error"}

@ -47,6 +47,9 @@ type TaskClient interface {
List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error) List(ctx context.Context, args *ListTaskArgs) (*ListTaskResult, error)
// DeleteByIDs deletes the tasks with the given IDs. // DeleteByIDs deletes the tasks with the given IDs.
DeleteByIDs(ctx context.Context, ids ...int) error DeleteByIDs(ctx context.Context, ids ...int) error
// HideByIDs marks tasks owned by the given user as hidden.
HideByIDs(ctx context.Context, ownerID int, ids ...int) error
// DeleteBy deletes the tasks with the given args. // DeleteBy deletes the tasks with the given args.
DeleteBy(ctx context.Context, args *DeleteTaskArgs) error DeleteBy(ctx context.Context, args *DeleteTaskArgs) error
} }
@ -58,6 +61,8 @@ type (
Status []task.Status Status []task.Status
UserID int UserID int
CorrelationID *uuid.UUID CorrelationID *uuid.UUID
// ExcludeHidden filters out tasks hidden by their owner.
ExcludeHidden bool
} }
ListTaskResult struct { ListTaskResult struct {
@ -124,6 +129,15 @@ func (c *taskClient) DeleteByIDs(ctx context.Context, ids ...int) error {
return err return err
} }
func (c *taskClient) HideByIDs(ctx context.Context, ownerID int, ids ...int) error {
_, err := c.client.Task.
Update().
Where(task.IDIn(ids...), task.UserTasks(ownerID)).
SetHidden(true).
Save(ctx)
return err
}
func (c *taskClient) DeleteBy(ctx context.Context, args *DeleteTaskArgs) error { func (c *taskClient) DeleteBy(ctx context.Context, args *DeleteTaskArgs) error {
query := c.client.Task. query := c.client.Task.
Delete(). Delete().
@ -226,6 +240,10 @@ func (c *taskClient) List(ctx context.Context, args *ListTaskArgs) (*ListTaskRes
q.Where(task.CorrelationID(*args.CorrelationID)) q.Where(task.CorrelationID(*args.CorrelationID))
} }
if args.ExcludeHidden {
q.Where(task.Hidden(false))
}
q = withTaskEagerLoading(ctx, q) q = withTaskEagerLoading(ctx, q)
var ( var (
tasks []*ent.Task tasks []*ent.Task

@ -80,6 +80,19 @@ func CancelTask(c *gin.Context) {
c.JSON(200, serializer.Response{}) c.JSON(200, serializer.Response{})
} }
// DeleteTask hides a finished task record from the owner's list.
func DeleteTask(c *gin.Context) {
taskId := hashid.FromContext(c)
err := explorer.DeleteTask(c, taskId)
if err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.JSON(200, serializer.Response{})
}
// RetryTask re-queues a failed task with its original args. // RetryTask re-queues a failed task with its original args.
func RetryTask(c *gin.Context) { func RetryTask(c *gin.Context) {
taskId := hashid.FromContext(c) taskId := hashid.FromContext(c)

@ -602,6 +602,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.HashID(hashid.TaskID), middleware.HashID(hashid.TaskID),
controllers.CancelTask, controllers.CancelTask,
) )
// Delete (hide) a finished task record
wf.DELETE(":id",
middleware.RequiredScopes(types.ScopeWorkflowWrite),
middleware.HashID(hashid.TaskID),
controllers.DeleteTask,
)
// Create task to create an archive file // Create task to create an archive file
wf.POST("archive", wf.POST("archive",
middleware.RequiredScopes(types.ScopeWorkflowWrite), middleware.RequiredScopes(types.ScopeWorkflowWrite),

@ -335,8 +335,9 @@ func (service *ListTaskService) ListTasks(c *gin.Context) (*TaskListResponse, er
PageToken: service.NextPageToken, PageToken: service.NextPageToken,
PageSize: service.PageSize, PageSize: service.PageSize,
}, },
Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType}, Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType},
UserID: user.ID, UserID: user.ID,
ExcludeHidden: true,
} }
if service.Category != "general" { if service.Category != "general" {
@ -519,6 +520,34 @@ func CancelTask(c *gin.Context, taskID int) error {
} }
} }
// DeleteTask hides a finished task record from its owner's list. The row
// is kept so admins retain the history (#2270 follow-up, user request).
func DeleteTask(c *gin.Context, taskID int) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
taskClient := dep.TaskClient()
model, err := taskClient.GetTaskByID(c, taskID)
if err != nil {
return serializer.NewError(serializer.CodeNotFound, "Task not found", err)
}
if model.UserTasks != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
}
switch model.Status {
case task.StatusCompleted, task.StatusError, task.StatusCanceled:
default:
return serializer.NewError(serializer.CodeParamErr, "Only finished tasks can be deleted", nil)
}
if err := taskClient.HideByIDs(c, model.UserTasks, taskID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete task", err)
}
return nil
}
type ( type (
SetDownloadFilesService struct { SetDownloadFilesService struct {
Files []*downloader.SetFileToDownloadArgs `json:"files" binding:"required"` Files []*downloader.SetFileToDownloadArgs `json:"files" binding:"required"`

@ -225,3 +225,73 @@ func TestCancelTask(t *testing.T) {
require.Error(t, CancelTask(newCtx(owner), m.ID)) require.Error(t, CancelTask(newCtx(owner), m.ID))
}) })
} }
func TestDeleteTask(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
owner := client.User.Create().SetEmail("del-owner@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
owner = client.User.Query().WithGroup().Where(entuser.ID(owner.ID)).OnlyX(ctx)
other := client.User.Create().SetEmail("del-other@example.com").SetNick("u").SetStatus("active").SetGroup(group).SaveX(ctx)
other = client.User.Query().WithGroup().Where(entuser.ID(other.ID)).OnlyX(ctx)
tc := inventory.NewTaskClient(client, conf.SQLiteDB, nil)
dep := &retryDepStub{
taskClient: tc,
ioQueue: &captureQueue{},
downloadQ: &captureQueue{},
recycleQ: &captureQueue{},
mediaMetaQ: &captureQueue{},
}
newCtx := func(u *ent.User) *gin.Context {
engine := gin.New()
engine.ContextWithFallback = true
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
c.Request = httptest.NewRequest("DELETE", "/", nil)
util.WithValue(c, dependency.DepCtx{}, dep)
util.WithValue(c, inventory.UserCtx{}, u)
return c
}
newTask := func(ownerID int, status enttask.Status) *ent.Task {
return client.Task.Create().
SetType(queue.ExtractArchiveTaskType).
SetStatus(status).
SetPublicState(&types.TaskPublicState{}).
SetCorrelationID(uuid.Must(uuid.NewV4())).
SetUserTasks(ownerID).
SaveX(ctx)
}
t.Run("finished task hidden from owner list", func(t *testing.T) {
m := newTask(owner.ID, enttask.StatusCompleted)
require.NoError(t, DeleteTask(newCtx(owner), m.ID))
require.True(t, client.Task.GetX(ctx, m.ID).Hidden)
res, err := tc.List(ctx, &inventory.ListTaskArgs{
PaginationArgs: &inventory.PaginationArgs{PageSize: 10},
UserID: owner.ID,
ExcludeHidden: true,
})
require.NoError(t, err)
for _, task := range res.Tasks {
require.NotEqual(t, m.ID, task.ID)
}
})
t.Run("running task rejected", func(t *testing.T) {
m := newTask(owner.ID, enttask.StatusProcessing)
require.Error(t, DeleteTask(newCtx(owner), m.ID))
require.False(t, client.Task.GetX(ctx, m.ID).Hidden)
})
t.Run("other user's task rejected", func(t *testing.T) {
m := newTask(owner.ID, enttask.StatusCompleted)
require.Error(t, DeleteTask(newCtx(other), m.ID))
require.False(t, client.Task.GetX(ctx, m.ID).Hidden)
})
}

Loading…
Cancel
Save