Repository: apache/airflow

Issue:
No Mass Delete Option for Task Instances Similar to What DAGRuns Have in UI
### Apache Airflow version

2.1.3 (latest released)

### Operating System

macOS Big Sur 11.3.1

### Versions of Apache Airflow Providers

n/a

### Deployment

Astronomer

### Deployment details

default Astronomer deployment with some test DAGs

### What happened

In the UI for DAGRuns there are checkboxes that allow multiple DAGRuns to be selected.

![image](https://user-images.githubusercontent.com/89415310/133841290-94cda771-9bb3-4677-8530-8c2861525719.png)

Within the Actions menu on this same view, there is a Delete option which allows multiple DAGRuns to be deleted at the same time.

![image](https://user-images.githubusercontent.com/89415310/133841362-3c7fc81d-a823-4f41-8b1d-64be425810ce.png)

Task instance view on the UI does not offer the same option, even though Task Instances can be individually deleted with the trash can button.

![image](https://user-images.githubusercontent.com/89415310/133841442-3fee0930-a6bb-4035-b172-1d10b85f3bf6.png)


### What you expected to happen

I expect that the Task Instances can also be bulk deleted, in the same way that DAGRuns can be.

### How to reproduce

Open up Task Instance and DAGRun views from the Browse tab and compare the options in the Actions dropdown menus.

### Anything else

_No response_

### Are you willing to submit PR?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:

Resolving patch:
diff --git a/airflow/www/views.py b/airflow/www/views.py
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -3896,7 +3896,7 @@ class DagRunModelView(AirflowPrivilegeVerifierModelView):
     class_permission_name = permissions.RESOURCE_DAG_RUN
     method_permission_name = {
         'list': 'read',
-        'action_clear': 'delete',
+        'action_clear': 'edit',
         'action_muldelete': 'delete',
         'action_set_running': 'edit',
         'action_set_failed': 'edit',
@@ -4204,6 +4204,7 @@ class TaskInstanceModelView(AirflowPrivilegeVerifierModelView):
     method_permission_name = {
         'list': 'read',
         'action_clear': 'edit',
+        'action_muldelete': 'delete',
         'action_set_running': 'edit',
         'action_set_failed': 'edit',
         'action_set_success': 'edit',
@@ -4338,6 +4339,13 @@ def action_clear(self, task_instances, session=None):
         self.update_redirect()
         return redirect(self.get_redirect())
 
+    @action('muldelete', 'Delete', "Are you sure you want to delete selected records?", single=False)
+    @action_has_dag_edit_access
+    def action_muldelete(self, items):
+        self.datamodel.delete_all(items)
+        self.update_redirect()
+        return redirect(self.get_redirect())
+
     @provide_session
     def set_task_instance_state(self, tis, target_state, session=None):
         """Set task instance state."""


Test patch:
diff --git a/tests/www/views/test_views_tasks.py b/tests/www/views/test_views_tasks.py
--- a/tests/www/views/test_views_tasks.py
+++ b/tests/www/views/test_views_tasks.py
@@ -25,7 +25,7 @@
 
 from airflow import settings
 from airflow.executors.celery_executor import CeleryExecutor
-from airflow.models import DagBag, DagModel, TaskInstance
+from airflow.models import DagBag, DagModel, TaskInstance, TaskReschedule
 from airflow.models.dagcode import DagCode
 from airflow.security import permissions
 from airflow.ti_deps.dependencies_states import QUEUEABLE_STATES, RUNNABLE_STATES
@@ -754,3 +754,60 @@ def test_set_task_instance_action_permission_denied(session, client_ti_without_d
         follow_redirects=True,
     )
     check_content_in_response(expected_message, resp)
+
+
+@pytest.mark.parametrize(
+    "task_search_tuples",
+    [
+        [("example_xcom", "bash_push"), ("example_bash_operator", "run_this_last")],
+        [("example_subdag_operator", "some-other-task")],
+    ],
+    ids=['multiple_tasks', 'one_task'],
+)
+def test_action_muldelete_task_instance(session, admin_client, task_search_tuples):
+    # get task instances to delete
+    tasks_to_delete = []
+    for task_search_tuple in task_search_tuples:
+        dag_id, task_id = task_search_tuple
+        tasks_to_delete.append(
+            session.query(TaskInstance)
+            .filter(TaskInstance.task_id == task_id, TaskInstance.dag_id == dag_id)
+            .one()
+        )
+
+    # add task reschedules for those tasks to make sure that the delete cascades to the required tables
+    trs = [
+        TaskReschedule(
+            task=task,
+            run_id=task.run_id,
+            try_number=1,
+            start_date=timezone.datetime(2021, 1, 1),
+            end_date=timezone.datetime(2021, 1, 2),
+            reschedule_date=timezone.datetime(2021, 1, 3),
+        )
+        for task in tasks_to_delete
+    ]
+    session.bulk_save_objects(trs)
+    session.flush()
+
+    # run the function to test
+    resp = admin_client.post(
+        "/taskinstance/action_post",
+        data={
+            "action": "muldelete",
+            "rowid": [_get_appbuilder_pk_string(TaskInstanceModelView, task) for task in tasks_to_delete],
+        },
+        follow_redirects=True,
+    )
+
+    # assert expected behavior for that function and its response
+    assert resp.status_code == 200
+    for task_search_tuple in task_search_tuples:
+        dag_id, task_id = task_search_tuple
+        assert (
+            session.query(TaskInstance)
+            .filter(TaskInstance.task_id == task_id, TaskInstance.dag_id == dag_id)
+            .count()
+            == 0
+        )
+    assert session.query(TaskReschedule).count() == 0